Improve support for documenting fields in req and resp payloads

This commit improves the support for documenting the fields found
in request and response payloads. The improvements include:

 - Using the term "payload" rather than "state" as the latter is
   somewhat HAL-specific
 - Code simplification, including the removal of the Path abstraction
 - Automatically determining the type of a field based on the payload
   that's being tested
 - Updating the samples to use the new API

In addition to these improvements, support for documenting field
constraints has been removed as more thought is required. The
FieldDescriptor abstraction is used to describe both request and
response fields, however it's not clear that contraints apply to both
and they certainly don't apply in the same way. For the time being at
least, users who want to document a field's constraints can do so as
part of its description.

Closes gh-24
This commit is contained in:
Andy Wilkinson
2015-03-23 18:03:17 +00:00
parent ecfc7abd92
commit 2b44452de1
23 changed files with 857 additions and 1056 deletions

View File

@@ -62,26 +62,9 @@ use of HTTP status codes.
== Errors
Whenever an error response (status code >= 400) is returned, the body will contain a JSON object
that describes the problem. The error object has the following fields:
that describes the problem. The error object has the following structure:
|===
| Field | Description
| error
| The HTTP error that occurred, e.g. `Bad Request`
| message
| A description of the cause of the error
| path
| The path to which the request was made
| status
| The HTTP status code, e.g. `400`
| timestamp
| The time, in milliseconds, at which the error occurred
|===
include::{generated}/error-example/response-fields.asciidoc[]
For example, a request that attempts to apply a non-existent tag to a note will produce a
`400 Bad Request` response:
@@ -116,12 +99,7 @@ A `GET` request is used to access the index
==== Response structure
|===
| JSON path | Description
| `_links`
| <<resources-index-links,Links>> to other resources
|===
include::{generated}/index-example/response-fields.asciidoc[]
==== Example response
@@ -132,15 +110,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[]
@@ -158,12 +128,7 @@ A `GET` request will list all of the service's notes.
==== Response structure
|===
| JSON path | Description
| `_embedded.notes`
| An array of <<resources-note,Note resources>>
|===
include::{generated}/notes-list-example/response-fields.asciidoc[]
==== Example request
@@ -182,18 +147,7 @@ A `POST` request is used to create a note
==== Request structure
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `tags`
| | The tags of the note as an array of URIs
|===
include::{generated}/notes-create-example/request-fields.asciidoc[]
==== Example request
@@ -219,12 +173,7 @@ A `GET` request will list all of the service's tags.
==== Response structure
|===
| JSON path | Description
| `_embedded.tags`
| An array of <<resources-tag,Tag resources>>
|===
include::{generated}/tags-list-example/response-fields.asciidoc[]
==== Example request
@@ -243,12 +192,7 @@ A `POST` request is used to create a note
==== Request structure
|===
| JSON path | Description
| `name`
| The name of the tag
|===
include::{generated}/tags-create-example/request-fields.asciidoc[]
==== Example request
@@ -270,15 +214,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-tags,tags>>
|===
include::{generated}/note-get-example/links.asciidoc[]
@@ -287,23 +223,18 @@ The Note resource is used to retrieve, update, and delete individual notes
A `GET` request will retrieve the details of a note
Example response:
==== Response structure
include::{generated}/note-get-example/response-fields.asciidoc[]
==== Example request
include::{generated}/note-get-example/request.asciidoc[]
==== Example response
include::{generated}/note-get-example/response.asciidoc[]
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `_links`
| <<resources-note-links,Links>> to other resources
|===
[[resources-note-update]]
@@ -313,18 +244,7 @@ A `PATCH` request is used to update a note
==== Request structure
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `tags`
| The tags of the note as an array of URIs
|===
include::{generated}/note-update-example/request-fields.asciidoc[]
To leave an attribute of a note unchanged, any of the above may be omitted from the request.
@@ -337,7 +257,8 @@ include::{generated}/note-update-example/request.asciidoc[]
include::{generated}/note-update-example/response.asciidoc[]
[[resources-note]]
[[resources-tag]]
== Tag
The Tag resource is used to retrieve, update, and delete individual tags
@@ -347,15 +268,7 @@ The Tag resource is used to retrieve, update, and delete individual tags
[[resources-tag-links]]
=== Links
|===
| Relation | Description
| self
| This <<resources-tag,tag>>
| notes
| The <<resources-notes,notes>> that have this tag
|===
include:{{generated}/tag-get-example/links.asciidoc
@@ -364,20 +277,18 @@ The Tag resource is used to retrieve, update, and delete individual tags
A `GET` request will retrieve the details of a tag
Example response:
==== Response structure
include::{generated}/tag-get-example/response-fields.asciidoc[]
==== Example request
include::{generated}/tag-get-example/request.asciidoc[]
==== Example response
include::{generated}/tag-get-example/response.asciidoc[]
|===
| JSON path | Description
| `name`
| The name of the tag
| `_links`
| <<resources-tag-links,Links>> to other resources
|===
[[resources-tag-update]]
@@ -387,13 +298,7 @@ A `PATCH` request is used to update a tag
==== Request structure
|===
| JSON path | Description
| `name`
| The name of the tag
|===
include::{generated}/tag-update-example/request-fields.asciidoc[]
==== Example request

View File

@@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.springframework.restdocs.RestDocumentation.document;
import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
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;
@@ -40,6 +41,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.hateoas.MediaTypes;
import org.springframework.restdocs.config.RestDocumentationConfigurer;
import org.springframework.restdocs.payload.FieldType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
@@ -87,20 +89,26 @@ public class ApiDocumentation {
.andExpect(jsonPath("timestamp", is(notNullValue())))
.andExpect(jsonPath("status", is(400)))
.andExpect(jsonPath("path", is(notNullValue())))
.andDo(document("error-example"));
.andDo(document("error-example")
.withResponseFields(
fieldWithPath("error").description("The HTTP error that occurred, e.g. `Bad Request`"),
fieldWithPath("message").description("A description of the cause of the error"),
fieldWithPath("path").description("The path to which the request was made"),
fieldWithPath("status").description("The HTTP status code, e.g. `400`"),
fieldWithPath("timestamp").description("The time, in milliseconds, at which the error occurred")));
}
@Test
public void indexExample() throws Exception {
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andDo(document("index-example").withLinks(
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")));
.andDo(document("index-example")
.withLinks(
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"))
.withResponseFields(
fieldWithPath("_links").description("<<resources-index-links,Links>> to other resources")));
}
@@ -116,7 +124,9 @@ public class ApiDocumentation {
this.mockMvc.perform(get("/notes"))
.andExpect(status().isOk())
.andDo(document("notes-list-example"));
.andDo(document("notes-list-example")
.withResponseFields(
fieldWithPath("_embedded.notes").description("An array of <<resources-note, Note resources>>")));
}
@Test
@@ -140,7 +150,11 @@ public class ApiDocumentation {
post("/notes").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(note))).andExpect(
status().isCreated())
.andDo(document("notes-create-example"));
.andDo(document("notes-create-example")
.withRequestFields(
fieldWithPath("title").description("The title of the note"),
fieldWithPath("body").description("The body of the note"),
fieldWithPath("tags").description("An array of tag resource URIs")));
}
@Test
@@ -176,9 +190,11 @@ public class ApiDocumentation {
.andDo(document("note-get-example")
.withLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("tags").description(
"This note's <<resources-note-tags,tags>>")));
linkWithRel("tags").description("This note's tags"))
.withResponseFields(
fieldWithPath("title").description("The title of the note"),
fieldWithPath("body").description("The body of the note"),
fieldWithPath("_links").description("<<resources-note-links,Links>> to other resources")));
}
@Test
@@ -192,7 +208,9 @@ public class ApiDocumentation {
this.mockMvc.perform(get("/tags"))
.andExpect(status().isOk())
.andDo(document("tags-list-example"));
.andDo(document("tags-list-example")
.withResponseFields(
fieldWithPath("_embedded.tags").description("An array of <<resources-tag,Tag resources>>")));
}
@Test
@@ -202,9 +220,11 @@ public class ApiDocumentation {
this.mockMvc.perform(
post("/tags").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tag))).andExpect(
status().isCreated())
.andDo(document("tags-create-example"));
this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andDo(document("tags-create-example")
.withRequestFields(
fieldWithPath("name").description("The name of the tag")));
}
@Test
@@ -243,7 +263,12 @@ public class ApiDocumentation {
patch(noteLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(noteUpdate)))
.andExpect(status().isNoContent())
.andDo(document("note-update-example"));
.andDo(document("note-update-example")
.withRequestFields(
fieldWithPath("title").description("The title of the note").type(FieldType.STRING).optional(),
fieldWithPath("body").description("The body of the note").type(FieldType.STRING).optional(),
fieldWithPath("tags").description("An array of tag resource URIs").optional()));
}
@Test
@@ -261,11 +286,13 @@ public class ApiDocumentation {
this.mockMvc.perform(get(tagLocation))
.andExpect(status().isOk())
.andExpect(jsonPath("name", is(tag.get("name"))))
.andDo(document("tag-get-example").withLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("notes")
.description(
"The <<resources-tagged-notes,notes>> that have this tag")));
.andDo(document("tag-get-example")
.withLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("notes").description("The <<resources-tagged-notes,notes>> that have this tag"))
.withResponseFields(
fieldWithPath("name").description("The name of the tag"),
fieldWithPath("_links").description("<<resources-tag-links,Links>> to other resources")));
}
@Test
@@ -287,7 +314,9 @@ public class ApiDocumentation {
patch(tagLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tagUpdate)))
.andExpect(status().isNoContent())
.andDo(document("tag-update-example"));
.andDo(document("tag-update-example")
.withRequestFields(
fieldWithPath("name").description("The name of the tag")));
}
private void createNote(String title, String body) {

View File

@@ -62,26 +62,9 @@ use of HTTP status codes.
== Errors
Whenever an error response (status code >= 400) is returned, the body will contain a JSON object
that describes the problem. The error object has the following fields:
that describes the problem. The error object has the following structure:
|===
| Field | Description
| error
| The HTTP error that occurred, e.g. `Bad Request`
| message
| A description of the cause of the error
| path
| The path to which the request was made
| status
| The HTTP status code, e.g. `400`
| timestamp
| The time, in milliseconds, at which the error occurred
|===
include::{generated}/error-example/response-fields.asciidoc
For example, a request that attempts to apply a non-existent tag to a note will produce a
`400 Bad Request` response:
@@ -116,12 +99,7 @@ A `GET` request is used to access the index
==== Response structure
|===
| JSON path | Description
| `_links`
| <<resources-index-links,Links>> to other resources
|===
include::{generated}/index-example/response-fields.asciidoc[]
==== Example response
@@ -150,12 +128,7 @@ A `GET` request will list all of the service's notes.
==== Response structure
|===
| JSON path | Description
| `_embedded.notes`
| An array of <<resources-note,Note resources>>
|===
include::{generated}/notes-list-example/response-fields.asciidoc[]
==== Example request
@@ -174,18 +147,7 @@ A `POST` request is used to create a note
==== Request structure
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `tags`
| | The tags of the note as an array of URIs
|===
include::{generated}/notes-create-example/request-fields.asciidoc[]
==== Example request
@@ -211,12 +173,7 @@ A `GET` request will list all of the service's tags.
==== Response structure
|===
| JSON path | Description
| `_embedded.tags`
| An array of <<resources-tag,Tag resources>>
|===
include::{generated}/tags-list-example/response-fields.asciidoc[]
==== Example request
@@ -235,12 +192,7 @@ A `POST` request is used to create a note
==== Request structure
|===
| JSON path | Description
| `name`
| The name of the tag
|===
include::{generated}/tags-create-example/request-fields.asciidoc[]
==== Example request
@@ -271,23 +223,18 @@ include::{generated}/note-get-example/links.asciidoc[]
A `GET` request will retrieve the details of a note
Example response:
==== Response structure
include::{generated}/note-get-example/response-fields.asciidoc[]
==== Example request
include::{generated}/note-get-example/request.asciidoc[]
==== Example response
include::{generated}/note-get-example/response.asciidoc[]
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `_links`
| <<resources-note-links,Links>> to other resources
|===
[[resources-note-update]]
@@ -297,18 +244,7 @@ A `PATCH` request is used to update a note
==== Request structure
|===
| JSON path | Description
| `title`
| The title of the note
| `body`
| The body of the note
| `tags`
| The tags of the note as an array of URIs
|===
include::{generated}/note-update-example/request-fields.asciidoc[]
To leave an attribute of a note unchanged, any of the above may be omitted from the request.
@@ -321,7 +257,7 @@ include::{generated}/note-update-example/request.asciidoc[]
include::{generated}/note-update-example/response.asciidoc[]
[[resources-note]]
[[resources-tag]]
== Tag
The Tag resource is used to retrieve, update, and delete individual tags
@@ -340,20 +276,18 @@ include::{generated}/tag-get-example/links.asciidoc[]
A `GET` request will retrieve the details of a tag
Example response:
==== Response structure
include::{generated}/tag-get-example/response-fields.asciidoc[]
==== Example request
include::{generated}/tag-get-example/request.asciidoc[]
==== Example response
include::{generated}/tag-get-example/response.asciidoc[]
|===
| JSON path | Description
| `name`
| The name of the tag
| `_links`
| <<resources-tag-links,Links>> to other resources
|===
[[resources-tag-update]]
@@ -363,13 +297,7 @@ A `PATCH` request is used to update a tag
==== Request structure
|===
| JSON path | Description
| `name`
| The name of the tag
|===
include::{generated}/tag-update-example/request-fields.asciidoc[]
==== Example request

View File

@@ -20,6 +20,7 @@ import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.springframework.restdocs.RestDocumentation.document;
import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel;
import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath;
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;
@@ -40,6 +41,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.hateoas.MediaTypes;
import org.springframework.restdocs.config.RestDocumentationConfigurer;
import org.springframework.restdocs.payload.FieldType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
@@ -87,18 +89,25 @@ public class ApiDocumentation {
.andExpect(jsonPath("timestamp", is(notNullValue())))
.andExpect(jsonPath("status", is(400)))
.andExpect(jsonPath("path", is(notNullValue())))
.andDo(document("error-example"));
.andDo(document("error-example")
.withResponseFields(
fieldWithPath("error").description("The HTTP error that occurred, e.g. `Bad Request`"),
fieldWithPath("message").description("A description of the cause of the error"),
fieldWithPath("path").description("The path to which the request was made"),
fieldWithPath("status").description("The HTTP status code, e.g. `400`"),
fieldWithPath("timestamp").description("The time, in milliseconds, at which the error occurred")));
}
@Test
public void indexExample() throws Exception {
this.mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andDo(document("index-example").withLinks(
linkWithRel("notes").description(
"The <<resources-notes,Notes resource>>"),
linkWithRel("tags").description(
"The <<resources-tags,Tags resource>>")));
.andDo(document("index-example")
.withLinks(
linkWithRel("notes").description("The <<resources-notes,Notes resource>>"),
linkWithRel("tags").description("The <<resources-tags,Tags resource>>"))
.withResponseFields(
fieldWithPath("_links").description("<<resources-index-links,Links>> to other resources")));
}
@Test
@@ -113,7 +122,9 @@ public class ApiDocumentation {
this.mockMvc.perform(get("/notes"))
.andExpect(status().isOk())
.andDo(document("notes-list-example"));
.andDo(document("notes-list-example")
.withResponseFields(
fieldWithPath("_embedded.notes").description("An array of <<resources-note, Note resources>>")));
}
@Test
@@ -137,7 +148,11 @@ public class ApiDocumentation {
post("/notes").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andDo(document("notes-create-example"));
.andDo(document("notes-create-example")
.withRequestFields(
fieldWithPath("title").description("The title of the note"),
fieldWithPath("body").description("The body of the note"),
fieldWithPath("tags").description("An array of tag resource URIs")));
}
@Test
@@ -170,10 +185,14 @@ public class ApiDocumentation {
.andExpect(jsonPath("body", is(note.get("body"))))
.andExpect(jsonPath("_links.self.href", is(noteLocation)))
.andExpect(jsonPath("_links.note-tags", is(notNullValue())))
.andDo(document("note-get-example").withLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("note-tags").description(
"This note's <<resources-note-tags,tags>>")));
.andDo(document("note-get-example")
.withLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("note-tags").description("This note's <<resources-note-tags,tags>>"))
.withResponseFields(
fieldWithPath("title").description("The title of the note"),
fieldWithPath("body").description("The body of the note"),
fieldWithPath("_links").description("<<resources-note-links,Links>> to other resources")));
}
@@ -188,7 +207,9 @@ public class ApiDocumentation {
this.mockMvc.perform(get("/tags"))
.andExpect(status().isOk())
.andDo(document("tags-list-example"));
.andDo(document("tags-list-example")
.withResponseFields(
fieldWithPath("_embedded.tags").description("An array of <<resources-tag,Tag resources>>")));
}
@Test
@@ -200,7 +221,9 @@ public class ApiDocumentation {
post("/tags").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andDo(document("tags-create-example"));
.andDo(document("tags-create-example")
.withRequestFields(
fieldWithPath("name").description("The name of the tag")));
}
@Test
@@ -239,7 +262,11 @@ public class ApiDocumentation {
patch(noteLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(noteUpdate)))
.andExpect(status().isNoContent())
.andDo(document("note-update-example"));
.andDo(document("note-update-example")
.withRequestFields(
fieldWithPath("title").description("The title of the note").type(FieldType.STRING).optional(),
fieldWithPath("body").description("The body of the note").type(FieldType.STRING).optional(),
fieldWithPath("tags").description("An array of tag resource URIs").optional()));
}
@Test
@@ -257,11 +284,13 @@ public class ApiDocumentation {
this.mockMvc.perform(get(tagLocation))
.andExpect(status().isOk())
.andExpect(jsonPath("name", is(tag.get("name"))))
.andDo(document("tag-get-example").withLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("tagged-notes")
.description(
"The <<resources-tagged-notes,notes>> that have this tag")));
.andDo(document("tag-get-example")
.withLinks(
linkWithRel("self").description("This <<resources-tag,tag>>"),
linkWithRel("tagged-notes").description("The <<resources-tagged-notes,notes>> that have this tag"))
.withResponseFields(
fieldWithPath("name").description("The name of the tag"),
fieldWithPath("_links").description("<<resources-tag-links,Links>> to other resources")));
}
@Test
@@ -283,7 +312,9 @@ public class ApiDocumentation {
patch(tagLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tagUpdate)))
.andExpect(status().isNoContent())
.andDo(document("tag-update-example"));
.andDo(document("tag-update-example")
.withRequestFields(
fieldWithPath("name").description("The name of the tag")));
}
private void createNote(String title, String body) {

View File

@@ -20,8 +20,8 @@ import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlRe
import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlRequestAndResponse;
import static org.springframework.restdocs.curl.CurlDocumentation.documentCurlResponse;
import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.documentLinks;
import static org.springframework.restdocs.state.StateDocumentation.documentRequestFields;
import static org.springframework.restdocs.state.StateDocumentation.documentResponseFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.documentRequestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.documentResponseFields;
import java.util.ArrayList;
import java.util.List;
@@ -30,9 +30,8 @@ import org.springframework.restdocs.hypermedia.HypermediaDocumentation;
import org.springframework.restdocs.hypermedia.LinkDescriptor;
import org.springframework.restdocs.hypermedia.LinkExtractor;
import org.springframework.restdocs.hypermedia.LinkExtractors;
import org.springframework.restdocs.state.FieldDescriptor;
import org.springframework.restdocs.state.Path;
import org.springframework.restdocs.state.StateDocumentation;
import org.springframework.restdocs.payload.FieldDescriptor;
import org.springframework.restdocs.payload.PayloadDocumentation;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.ResultHandler;
@@ -104,17 +103,16 @@ public class RestDocumentationResultHandler implements ResultHandler {
}
/**
* Document the fields in the response using the given {@code descriptors}. The fields
* are extracted from the response based on its content type.
* Document the fields in the request using the given {@code descriptors}.
* <p>
* If a field is present in the response but is not described by one of the
* descriptors a failure will occur when this handler is invoked. Similarly, if a
* field is described but is not present in the response a failure will also occur
* when this handler is invoked.
* If a field is present in the request but is not described by one of the descriptors
* a failure will occur when this handler is invoked. Similarly, if a field is
* described but is not present in the request a failure will also occur when this
* handler is invoked.
*
* @param descriptors the link descriptors
* @return {@code this}
* @see StateDocumentation#fieldWithPath(Path)
* @see PayloadDocumentation#fieldWithPath(String)
*/
public RestDocumentationResultHandler withRequestFields(
FieldDescriptor... descriptors) {
@@ -123,8 +121,7 @@ public class RestDocumentationResultHandler implements ResultHandler {
}
/**
* Document the fields in the response using the given {@code descriptors}. The fields
* are extracted from the response based on its content type.
* Document the fields in the response using the given {@code descriptors}.
* <p>
* If a field is present in the response but is not described by one of the
* descriptors a failure will occur when this handler is invoked. Similarly, if a
@@ -133,7 +130,7 @@ public class RestDocumentationResultHandler implements ResultHandler {
*
* @param descriptors the link descriptors
* @return {@code this}
* @see StateDocumentation#fieldWithPath(Path)
* @see PayloadDocumentation#fieldWithPath(String)
*/
public RestDocumentationResultHandler withResponseFields(
FieldDescriptor... descriptors) {

View File

@@ -14,61 +14,49 @@
* limitations under the License.
*/
package org.springframework.restdocs.state;
package org.springframework.restdocs.payload;
/**
* A description of a field found in a hypermedia API
* A description of a field found in a request or response payload
*
* @see StateDocumentation#fieldWithPath(Path)
* @see PayloadDocumentation#fieldWithPath(String)
*
* @author Andreas Evers
* @author Andy Wilkinson
*/
public class FieldDescriptor {
private final Path path;
private final String path;
private String type;
private FieldType type;
private boolean required;
private String constraints;
private boolean optional;
private String description;
FieldDescriptor(Path path) {
FieldDescriptor(String path) {
this.path = path;
}
/**
* Specifies the type of the field
*
* @param type The field's type (could be number, string, boolean, array, object, ...)
* @param type The type of the field
*
* @return {@code this}
*/
public FieldDescriptor type(String type) {
public FieldDescriptor type(FieldType type) {
this.type = type;
return this;
}
/**
* Specifies necessity of the field
* Marks the field as optional
*
* @param required The field's necessity
* @return {@code this}
*/
public FieldDescriptor required(boolean required) {
this.required = required;
return this;
}
/**
* Specifies the constraints of the field
*
* @param constraints The field's constraints
* @return {@code this}
*/
public FieldDescriptor constraints(String constraints) {
this.constraints = constraints;
public FieldDescriptor optional() {
this.optional = true;
return this;
}
@@ -83,20 +71,16 @@ public class FieldDescriptor {
return this;
}
Path getPath() {
String getPath() {
return this.path;
}
String getType() {
FieldType getType() {
return this.type;
}
boolean isRequired() {
return this.required;
}
String getConstraints() {
return this.constraints;
boolean isOptional() {
return this.optional;
}
String getDescription() {

View File

@@ -0,0 +1,52 @@
package org.springframework.restdocs.payload;
import java.util.Map;
/**
* A {@link FieldExtractor} extracts a field from a payload
*
* @author Andy Wilkinson
*
*/
class FieldExtractor {
boolean hasField(String path, Map<String, Object> payload) {
String[] segments = path.indexOf('.') > -1 ? path.split("\\.")
: new String[] { path };
Object current = payload;
for (String segment : segments) {
if (current instanceof Map && ((Map<?, ?>) current).containsKey(segment)) {
current = ((Map<?, ?>) current).get(segment);
}
else {
return false;
}
}
return true;
}
Object extractField(String path, Map<String, Object> payload) {
String[] segments = path.indexOf('.') > -1 ? path.split("\\.")
: new String[] { path };
Object current = payload;
for (String segment : segments) {
if (current instanceof Map && ((Map<?, ?>) current).containsKey(segment)) {
current = ((Map<?, ?>) current).get(segment);
}
else {
throw new IllegalArgumentException(
"The payload does not contain a field with the path '" + path
+ "'");
}
}
return current;
}
}

View File

@@ -0,0 +1,122 @@
/*
* 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.payload;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.restdocs.snippet.DocumentationWriter;
import org.springframework.restdocs.snippet.DocumentationWriter.TableAction;
import org.springframework.restdocs.snippet.DocumentationWriter.TableWriter;
import org.springframework.restdocs.snippet.SnippetWritingResultHandler;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* A {@link SnippetWritingResultHandler} that produces a snippet documenting a RESTful
* resource's request or response fields.
*
* @author Andreas Evers
* @author Andy Wilkinson
*/
public abstract class FieldSnippetResultHandler extends SnippetWritingResultHandler {
private final Map<String, FieldDescriptor> descriptorsByPath = new LinkedHashMap<String, FieldDescriptor>();
private final FieldTypeResolver fieldTypeResolver = new FieldTypeResolver();
private final FieldExtractor fieldExtractor = new FieldExtractor();
private final FieldValidator fieldValidator = new FieldValidator();
private final ObjectMapper objectMapper = new ObjectMapper();
private List<FieldDescriptor> fieldDescriptors;
FieldSnippetResultHandler(String outputDir, String filename,
List<FieldDescriptor> descriptors) {
super(outputDir, filename + "-fields");
for (FieldDescriptor descriptor : descriptors) {
Assert.notNull(descriptor.getPath());
Assert.hasText(descriptor.getDescription());
this.descriptorsByPath.put(descriptor.getPath(), descriptor);
}
this.fieldDescriptors = descriptors;
}
@Override
protected void handle(MvcResult result, DocumentationWriter writer)
throws IOException {
this.fieldValidator.validate(getPayloadReader(result), this.fieldDescriptors);
final Map<String, Object> payload = extractPayload(result);
List<String> missingFields = new ArrayList<String>();
for (FieldDescriptor fieldDescriptor : this.fieldDescriptors) {
if (!fieldDescriptor.isOptional()) {
Object field = this.fieldExtractor.extractField(
fieldDescriptor.getPath(), payload);
if (field == null) {
missingFields.add(fieldDescriptor.getPath());
}
}
}
writer.table(new TableAction() {
@Override
public void perform(TableWriter tableWriter) throws IOException {
tableWriter.headers("Path", "Type", "Description");
for (Entry<String, FieldDescriptor> entry : FieldSnippetResultHandler.this.descriptorsByPath
.entrySet()) {
FieldDescriptor descriptor = entry.getValue();
FieldType type = descriptor.getType() != null ? descriptor.getType()
: FieldSnippetResultHandler.this.fieldTypeResolver
.resolveFieldType(descriptor.getPath(), payload);
tableWriter.row(entry.getKey().toString(), type.toString(), entry
.getValue().getDescription());
}
}
});
}
@SuppressWarnings("unchecked")
private Map<String, Object> extractPayload(MvcResult result) throws IOException {
Reader payloadReader = getPayloadReader(result);
try {
return this.objectMapper.readValue(payloadReader, Map.class);
}
finally {
payloadReader.close();
}
}
protected abstract Reader getPayloadReader(MvcResult result) throws IOException;
}

View File

@@ -0,0 +1,36 @@
/*
* 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.payload;
import java.util.Locale;
import org.springframework.util.StringUtils;
/**
* An enumeration of the possible types for a field in a JSON request or response payload
*
* @author Andy Wilkinson
*/
public enum FieldType {
ARRAY, BOOLEAN, OBJECT, NUMBER, NULL, STRING;
@Override
public String toString() {
return StringUtils.capitalize(this.name().toLowerCase(Locale.ENGLISH));
}
}

View File

@@ -0,0 +1,53 @@
/*
* 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.payload;
import java.util.Collection;
import java.util.Map;
/**
* Resolves the type of a field in a request or response payload
*
* @author Andy Wilkinson
*/
class FieldTypeResolver {
private final FieldExtractor fieldExtractor = new FieldExtractor();
FieldType resolveFieldType(String path, Map<String, Object> payload) {
return determineFieldType(this.fieldExtractor.extractField(path, payload));
}
private FieldType determineFieldType(Object fieldValue) {
if (fieldValue == null) {
return FieldType.NULL;
}
if (fieldValue instanceof String) {
return FieldType.STRING;
}
if (fieldValue instanceof Map) {
return FieldType.OBJECT;
}
if (fieldValue instanceof Collection) {
return FieldType.ARRAY;
}
if (fieldValue instanceof Boolean) {
return FieldType.BOOLEAN;
}
return FieldType.NUMBER;
}
}

View File

@@ -0,0 +1,119 @@
/*
* 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.payload;
import java.io.IOException;
import java.io.Reader;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
/**
* {@code FieldValidator} is used to validate a payload's fields against the user-provided
* {@link FieldDescriptor}s.
*
* @author Andy Wilkinson
*/
class FieldValidator {
private final FieldExtractor fieldExtractor = new FieldExtractor();
private final ObjectMapper objectMapper = new ObjectMapper()
.enable(SerializationFeature.INDENT_OUTPUT);
@SuppressWarnings("unchecked")
void validate(Reader payloadReader, List<FieldDescriptor> fieldDescriptors)
throws IOException {
Map<String, Object> payload = this.objectMapper.readValue(payloadReader,
Map.class);
List<String> missingFields = findMissingFields(payload, fieldDescriptors);
Map<String, Object> undocumentedPayload = findUndocumentedFields(payload,
fieldDescriptors);
if (!missingFields.isEmpty() || !undocumentedPayload.isEmpty()) {
String message = "";
if (!undocumentedPayload.isEmpty()) {
message += String.format(
"Portions of the payload were not documented:%n%s",
this.objectMapper.writeValueAsString(undocumentedPayload));
}
if (!missingFields.isEmpty()) {
message += "Fields with the following paths were not found in the payload: "
+ missingFields;
}
throw new FieldValidationException(message);
}
}
private List<String> findMissingFields(Map<String, Object> payload,
List<FieldDescriptor> fieldDescriptors) {
List<String> missingFields = new ArrayList<String>();
for (FieldDescriptor fieldDescriptor : fieldDescriptors) {
if (!fieldDescriptor.isOptional()) {
if (!this.fieldExtractor.hasField(fieldDescriptor.getPath(), payload)) {
missingFields.add(fieldDescriptor.getPath());
}
}
}
return missingFields;
}
private Map<String, Object> findUndocumentedFields(Map<String, Object> payload,
List<FieldDescriptor> fieldDescriptors) {
for (FieldDescriptor fieldDescriptor : fieldDescriptors) {
String path = fieldDescriptor.getPath();
List<String> segments = path.indexOf('.') > -1 ? Arrays.asList(path
.split("\\.")) : Arrays.asList(path);
removeField(segments, 0, payload);
}
return payload;
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private void removeField(List<String> segments, int depth,
Map<String, Object> payloadPortion) {
String key = segments.get(depth);
if (depth == segments.size() - 1) {
payloadPortion.remove(key);
}
else {
Object candidate = payloadPortion.get(key);
if (candidate instanceof Map) {
Map map = (Map<?, ?>) candidate;
removeField(segments, depth + 1, map);
if (map.isEmpty()) {
payloadPortion.remove(key);
}
}
}
}
@SuppressWarnings("serial")
static class FieldValidationException extends RuntimeException {
FieldValidationException(String message) {
super(message);
}
}
}

View File

@@ -14,24 +14,21 @@
* limitations under the License.
*/
package org.springframework.restdocs.state;
import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST;
import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.RESPONSE;
import static org.springframework.restdocs.state.Path.path;
package org.springframework.restdocs.payload;
import java.util.Arrays;
import org.springframework.restdocs.RestDocumentationResultHandler;
/**
* Static factory methods for documenting a RESTful API's state.
* Static factory methods for documenting a RESTful API's request and response payloads.
*
* @author Andreas Evers
* @author Andy Wilkinson
*/
public abstract class StateDocumentation {
public abstract class PayloadDocumentation {
private StateDocumentation() {
private PayloadDocumentation() {
}
@@ -44,23 +41,10 @@ public abstract class StateDocumentation {
* @see RestDocumentationResultHandler#withRequestFields(FieldDescriptor...)
* @see RestDocumentationResultHandler#withResponseFields(FieldDescriptor...)
*/
public static FieldDescriptor fieldWithPath(Path path) {
public static FieldDescriptor fieldWithPath(String path) {
return new FieldDescriptor(path);
}
/**
* Creates a {@code FieldDescriptor} that describes a field with the given
* {@code path}, in case the field is at the root of the request or response body
*
* @param name The name of the field being at the root of the request or response body
* @return a {@code FieldDescriptor} ready for further configuration
* @see RestDocumentationResultHandler#withRequestFields(FieldDescriptor...)
* @see RestDocumentationResultHandler#withResponseFields(FieldDescriptor...)
*/
public static FieldDescriptor fieldWithPath(String name) {
return new FieldDescriptor(path(name));
}
/**
* Creates a {@code RequestFieldsSnippetResultHandler} that will produce a
* documentation snippet for a request's fields.
@@ -72,8 +56,7 @@ public abstract class StateDocumentation {
*/
public static FieldSnippetResultHandler documentRequestFields(String outputDir,
FieldDescriptor... descriptors) {
return new FieldSnippetResultHandler(outputDir, REQUEST,
Arrays.asList(descriptors));
return new RequestFieldSnippetResultHandler(outputDir, Arrays.asList(descriptors));
}
/**
@@ -87,7 +70,7 @@ public abstract class StateDocumentation {
*/
public static FieldSnippetResultHandler documentResponseFields(String outputDir,
FieldDescriptor... descriptors) {
return new FieldSnippetResultHandler(outputDir, RESPONSE,
return new ResponseFieldSnippetResultHandler(outputDir,
Arrays.asList(descriptors));
}

View File

@@ -0,0 +1,40 @@
/*
* 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.payload;
import java.io.IOException;
import java.io.Reader;
import java.util.List;
import org.springframework.test.web.servlet.MvcResult;
/**
* A {@link FieldSnippetResultHandler} for documenting a request's fields
*
* @author Andy Wilkinson
*/
public class RequestFieldSnippetResultHandler extends FieldSnippetResultHandler {
RequestFieldSnippetResultHandler(String outputDir, List<FieldDescriptor> descriptors) {
super(outputDir, "request", descriptors);
}
@Override
protected Reader getPayloadReader(MvcResult result) throws IOException {
return result.getRequest().getReader();
}
}

View File

@@ -0,0 +1,41 @@
/*
* 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.payload;
import java.io.IOException;
import java.io.Reader;
import java.io.StringReader;
import java.util.List;
import org.springframework.test.web.servlet.MvcResult;
/**
* A {@link FieldSnippetResultHandler} for documenting a response's fields
*
* @author Andy Wilkinson
*/
public class ResponseFieldSnippetResultHandler extends FieldSnippetResultHandler {
ResponseFieldSnippetResultHandler(String outputDir, List<FieldDescriptor> descriptors) {
super(outputDir, "response", descriptors);
}
@Override
protected Reader getPayloadReader(MvcResult result) throws IOException {
return new StringReader(result.getResponse().getContentAsString());
}
}

View File

@@ -1,95 +0,0 @@
/*
* 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.state;
import org.springframework.core.style.ToStringCreator;
/**
* Representation of a field used in a Hypermedia-based API
*
* @author Andreas Evers
*/
public class Field {
private final Path path;
private final Object value;
/**
* Creates a new {@code Field} with the given {@code path} and {@code value}
*
* @param path The field's path
* @param value The field's value
*/
public Field(Path path, Object value) {
this.path = path;
this.value = value;
}
/**
* Returns the field's {@code path}
* @return the field's {@code path}
*/
public Path getPath() {
return this.path;
}
/**
* Returns the field's {@code value}
* @return the field's {@code value}
*/
public Object getValue() {
return this.value;
}
@Override
public int hashCode() {
int prime = 31;
int result = 1;
result = prime * result + this.path.hashCode();
result = prime * result + this.value.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;
}
Field other = (Field) obj;
if (!this.path.equals(other.path)) {
return false;
}
if (!this.value.equals(other.value)) {
return false;
}
return true;
}
@Override
public String toString() {
return new ToStringCreator(this).append("path", this.path)
.append("value", this.value).toString();
}
}

View File

@@ -1,86 +0,0 @@
/*
* 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.state;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.util.Assert;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* A {@code FieldExtractor} is used to extract {@link Field fields} from a JSON response.
* The expected format of the links in the response is determined by the implementation.
*
* @author Andy Wilkinson
*
*/
public class FieldExtractor {
private final ObjectMapper objectMapper = new ObjectMapper();
private Map<Path, Field> extractedFields = new HashMap<>();
@SuppressWarnings("unchecked")
public Map<Path, Field> extractFields(MockHttpServletRequest request)
throws IOException {
Map<String, Object> jsonContent = this.objectMapper.readValue(
request.getInputStream(), Map.class);
extractFieldsRecursively(jsonContent);
return this.extractedFields;
}
@SuppressWarnings("unchecked")
public Map<Path, Field> extractFields(MockHttpServletResponse response)
throws IOException {
String responseBody = response.getContentAsString();
Assert.hasText(responseBody,
"The response doesn't contain a body to extract fields from");
Map<String, Object> jsonContent = this.objectMapper.readValue(responseBody,
Map.class);
extractFieldsRecursively(jsonContent);
return this.extractedFields;
}
private void extractFieldsRecursively(Map<String, Object> jsonContent) {
extractFieldsRecursively(null, jsonContent);
}
@SuppressWarnings("unchecked")
private void extractFieldsRecursively(Path previousSteps,
Map<String, Object> jsonContent) {
for (Entry<String, Object> entry : jsonContent.entrySet()) {
Path path;
if (previousSteps == null) {
path = new Path(entry.getKey());
}
else {
path = new Path(previousSteps, entry.getKey());
}
this.extractedFields.put(path, new Field(path, entry.getValue()));
if (entry.getValue() instanceof Map) {
Map<String, Object> value = (Map<String, Object>) entry.getValue();
extractFieldsRecursively(path, value);
}
}
}
}

View File

@@ -1,98 +0,0 @@
/*
* 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.state;
import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.SortedSet;
import java.util.TreeSet;
import org.springframework.restdocs.snippet.DocumentationWriter;
import org.springframework.restdocs.snippet.SnippetWritingResultHandler;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.util.Assert;
/**
* A {@link SnippetWritingResultHandler} that produces a snippet documenting a RESTful
* resource's request or response fields.
*
* @author Andreas Evers
*/
public class FieldSnippetResultHandler extends SnippetWritingResultHandler {
private final Map<Path, FieldDescriptor> descriptorsByName = new HashMap<Path, FieldDescriptor>();
private final Type type;
private FieldExtractor extractor = new FieldExtractor();
private StateDocumentationValidator validator;
enum Type {
REQUEST, RESPONSE;
}
FieldSnippetResultHandler(String outputDir, FieldSnippetResultHandler.Type type,
List<FieldDescriptor> descriptors) {
super(outputDir, type.toString().toLowerCase() + "fields");
this.type = type;
for (FieldDescriptor descriptor : descriptors) {
Assert.notNull(descriptor.getPath());
Assert.hasText(descriptor.getDescription());
this.descriptorsByName.put(descriptor.getPath(), descriptor);
}
this.validator = new StateDocumentationValidator(type);
}
@Override
protected void handle(MvcResult result, DocumentationWriter writer)
throws IOException {
Map<Path, Field> fields;
if (this.type == REQUEST) {
fields = this.extractor.extractFields(result.getRequest());
}
else {
fields = this.extractor.extractFields(result.getResponse());
}
SortedSet<Path> actualFields = new TreeSet<Path>(fields.keySet());
SortedSet<Path> expectedFields = new TreeSet<Path>(
this.descriptorsByName.keySet());
this.validator.validateFields(actualFields, expectedFields);
writer.println("|===");
writer.println("| Path | Description | Type | Required | Constraints");
for (Entry<Path, FieldDescriptor> entry : this.descriptorsByName.entrySet()) {
writer.println();
writer.println("| " + entry.getKey());
writer.println("| " + entry.getValue().getDescription());
writer.println("| " + entry.getValue().getType());
writer.println("| " + entry.getValue().isRequired());
writer.println("| " + entry.getValue().getConstraints());
}
writer.println("|===");
}
}

View File

@@ -1,124 +0,0 @@
/*
* 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.state;
import static java.lang.Math.min;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.core.style.ToStringCreator;
/**
* Representation of a path for a field. In case the field is a nested field, there will
* be multiple steps. Each step is the name of a field, albeit parents of the field in
* question. In case the field is not nested, there is only one step, which is the name of the field.
*
* @author Andreas Evers
*/
public class Path implements Comparable<Path> {
private List<String> steps = new ArrayList<>();
public Path(Path path) {
this.steps = new ArrayList<String>(path.getSteps());
}
public Path(List<String> steps) {
this.steps = steps;
}
public Path(Path previousSteps, String newStep) {
this.steps.addAll(previousSteps.getSteps());
this.steps.add(newStep);
}
public Path(String... steps) {
this.steps = Arrays.asList(steps);
}
public static Path path(List<String> steps) {
return new Path(steps);
}
public static Path path(String... steps) {
return new Path(steps);
}
public static Path path(Path previousSteps, String newStep) {
return new Path(previousSteps, newStep);
}
public List<String> getSteps() {
return this.steps;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((this.steps == null) ? 0 : this.steps.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;
}
Path other = (Path) obj;
if (this.steps == null) {
if (other.steps != null) {
return false;
}
}
else if (!this.steps.equals(other.steps)) {
return false;
}
return true;
}
@Override
public String toString() {
return new ToStringCreator(this).append("steps", this.steps).toString();
}
@Override
public int compareTo(Path o) {
int comparison = 0;
int size = min(this.steps.size(), o.getSteps().size());
for (int i = 0; i < size; i++) {
String thisStep = this.steps.get(i);
String thatStep = o.getSteps().get(i);
comparison = thisStep.compareTo(thatStep);
if (comparison != 0) {
break;
}
}
if (comparison == 0) {
comparison = ((Integer) this.steps.size()).compareTo(o.getSteps().size());
}
return comparison;
}
}

View File

@@ -1,75 +0,0 @@
/*
* 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.state;
import static org.junit.Assert.fail;
import java.util.HashSet;
import java.util.Set;
import java.util.SortedSet;
import org.springframework.restdocs.state.FieldSnippetResultHandler.Type;
/**
* Validator which verifies if fields are documented correctly. All fields need to be
* documented except nested fields. For those fields it is sufficient that only the parent
* is documented. In case there are fields documented that don't appear in the actual
* request or response, the validation will fail.
*
* @author Andreas Evers
*/
public class StateDocumentationValidator {
private final Type type;
public StateDocumentationValidator(Type type) {
this.type = type;
}
public void validateFields(SortedSet<Path> actualFields,
SortedSet<Path> expectedFields) {
Set<Path> undocumentedFields = new HashSet<Path>(actualFields);
Set<Path> ignoredFields = new HashSet<Path>();
undocumentedFields.removeAll(expectedFields);
for (Path path : undocumentedFields) {
if (path.getSteps().size() > 1) {
Path wrappingPath = new Path(path);
wrappingPath.getSteps().remove(wrappingPath.getSteps().size() - 1);
if (actualFields.contains(wrappingPath)) {
ignoredFields.add(path);
}
}
}
undocumentedFields.removeAll(ignoredFields);
Set<Path> missingFields = new HashSet<Path>(expectedFields);
missingFields.removeAll(actualFields);
if (!undocumentedFields.isEmpty() || !missingFields.isEmpty()) {
String message = "";
if (!undocumentedFields.isEmpty()) {
message += "Fields with the following paths were not documented: "
+ undocumentedFields;
}
if (!missingFields.isEmpty()) {
message += "Fields with the following paths were not found in the "
+ this.type.toString().toLowerCase() + ": " + missingFields;
}
fail(message);
}
}
}

View File

@@ -0,0 +1,103 @@
/*
* 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.payload;
import static org.hamcrest.CoreMatchers.equalTo;
import static org.junit.Assert.assertThat;
import java.io.IOException;
import java.util.Map;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import com.fasterxml.jackson.databind.ObjectMapper;
/**
* Tests for {@link FieldTypeResolver}
*
* @author Andy Wilkinson
*
*/
public class FieldTypeResolverTests {
private final FieldTypeResolver fieldTypeResolver = new FieldTypeResolver();
@Rule
public ExpectedException thrownException = ExpectedException.none();
@Test
public void arrayField() throws IOException {
assertFieldType(FieldType.ARRAY, "[]");
}
@Test
public void booleanField() throws IOException {
assertFieldType(FieldType.BOOLEAN, "true");
}
@Test
public void objectField() throws IOException {
assertFieldType(FieldType.OBJECT, "{}");
}
@Test
public void nullField() throws IOException {
assertFieldType(FieldType.NULL, "null");
}
@Test
public void numberField() throws IOException {
assertFieldType(FieldType.NUMBER, "1.2345");
}
@Test
public void stringField() throws IOException {
assertFieldType(FieldType.STRING, "\"Foo\"");
}
@Test
public void nestedField() throws IOException {
assertThat(this.fieldTypeResolver.resolveFieldType("a.b.c",
createPayload("{\"a\":{\"b\":{\"c\":{}}}}")), equalTo(FieldType.OBJECT));
}
@Test
public void nonExistentFieldProducesIllegalArgumentException() throws IOException {
this.thrownException.expect(IllegalArgumentException.class);
this.thrownException
.expectMessage("The payload does not contain a field with the path 'a.b'");
this.fieldTypeResolver.resolveFieldType("a.b", createPayload("{\"a\":{}}"));
}
private void assertFieldType(FieldType expectedType, String jsonValue)
throws IOException {
assertThat(this.fieldTypeResolver.resolveFieldType("field",
createSimplePayload(jsonValue)), equalTo(expectedType));
}
private Map<String, Object> createSimplePayload(String value) throws IOException {
return createPayload("{\"field\":" + value + "}");
}
@SuppressWarnings("unchecked")
private Map<String, Object> createPayload(String json) throws IOException {
return new ObjectMapper().readValue(json, Map.class);
}
}

View File

@@ -0,0 +1,88 @@
/*
* 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.payload;
import static org.hamcrest.CoreMatchers.equalTo;
import java.io.IOException;
import java.io.StringReader;
import java.util.Arrays;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.restdocs.payload.FieldValidator.FieldValidationException;
/**
* Tests for {@link FieldValidator}
*
* @author Andy Wilkinson
*/
public class FieldValidatorTests {
private final FieldValidator fieldValidator = new FieldValidator();
@Rule
public ExpectedException thrownException = ExpectedException.none();
private StringReader payload = new StringReader("{\"a\":{\"b\":{}, \"c\":true}}");
@Test
public void noMissingFieldsAllFieldsDocumented() throws IOException {
this.fieldValidator.validate(this.payload, Arrays.asList(
new FieldDescriptor("a"), new FieldDescriptor("a.b"),
new FieldDescriptor("a.c")));
}
@Test
public void optionalFieldsAreNotReportedMissing() throws IOException {
this.fieldValidator.validate(this.payload, Arrays.asList(
new FieldDescriptor("a"), new FieldDescriptor("a.b"),
new FieldDescriptor("a.c"), new FieldDescriptor("y").optional()));
}
@Test
public void parentIsDocumentedWhenAllChildrenAreDocumented() throws IOException {
this.fieldValidator.validate(this.payload,
Arrays.asList(new FieldDescriptor("a.b"), new FieldDescriptor("a.c")));
}
@Test
public void childIsDocumentedWhenParentIsDocumented() throws IOException {
this.fieldValidator.validate(this.payload,
Arrays.asList(new FieldDescriptor("a")));
}
@Test
public void missingField() throws IOException {
this.thrownException.expect(FieldValidationException.class);
this.thrownException
.expectMessage(equalTo("Fields with the following paths were not found in the payload: [y, z]"));
this.fieldValidator.validate(this.payload, Arrays.asList(
new FieldDescriptor("a"), new FieldDescriptor("a.b"),
new FieldDescriptor("y"), new FieldDescriptor("z")));
}
@Test
public void undocumentedField() throws IOException {
this.thrownException.expect(FieldValidationException.class);
this.thrownException
.expectMessage(equalTo(String
.format("Portions of the payload were not documented:%n{%n \"a\" : {%n \"c\" : true%n }%n}")));
this.fieldValidator.validate(this.payload,
Arrays.asList(new FieldDescriptor("a.b")));
}
}

View File

@@ -1,139 +0,0 @@
/*
* 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.state;
import static org.junit.Assert.assertEquals;
import static org.springframework.restdocs.state.Path.path;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.util.FileCopyUtils;
/**
* Tests for {@link FieldExtractor}.
*
* @author Andreas Evers
*/
public class FieldExtractorTests {
private final FieldExtractor fieldExtractor = new FieldExtractor();
@Test
public void singleField() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("single-field"));
assertFields(Arrays.asList(new Field(path("alpha"), "alpha-value")), fields);
}
@Test
public void multipleFields() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("multiple-fields"));
assertFields(Arrays.asList(new Field(path("alpha"), "alpha-value"), new Field(
path("bravo"), 123), new Field(path("charlie"), createMap()), new Field(
path("delta"), createList()), new Field(path("echo"),
createListWithMaps())), fields);
}
private Map<String, Object> createMap() {
Map<String, Object> hashMap = new HashMap<>();
hashMap.put("one", 456);
hashMap.put("two", "two-value");
return hashMap;
}
private List<String> createList() {
List<String> arrayList = new ArrayList<>();
arrayList.add("delta-value-1");
arrayList.add("delta-value-2");
return arrayList;
}
private List<Map<String, Object>> createListWithMaps() {
List<Map<String, Object>> arrayList = new ArrayList<>();
Map<String, Object> hashMap1 = new HashMap<>();
hashMap1.put("one", 789);
hashMap1.put("two", "two-value");
arrayList.add(hashMap1);
Map<String, Object> hashMap2 = new HashMap<>();
hashMap2.put("one", 987);
hashMap2.put("two", "value-two");
arrayList.add(hashMap2);
return arrayList;
}
@Test
public void multipleFieldsAndLinks() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("multiple-fields-and-links"));
assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field(
path("charlie"), "charlie-value")), fields);
}
@Test
public void multipleFieldsAndEmbedded() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("multiple-fields-and-embedded"));
assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field(
path("charlie"), "charlie-value")), fields);
}
@Test
public void multipleFieldsAndEmbeddedAndLinks() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("multiple-fields-and-embedded-and-links"));
assertFields(Arrays.asList(new Field(path("beta"), "beta-value"), new Field(
path("charlie"), "charlie-value")), fields);
}
@Test
public void noFields() throws IOException {
Map<Path, Field> fields = this.fieldExtractor
.extractFields(createResponse("no-fields"));
assertFields(Collections.<Field> emptyList(), fields);
}
private void assertFields(List<Field> expectedFields, Map<Path, Field> actualFields) {
Map<Path, Field> expectedFieldsByName = new HashMap<>();
for (Field expectedField : expectedFields) {
expectedFieldsByName.put(expectedField.getPath(), expectedField);
}
assertEquals(expectedFieldsByName, actualFields);
}
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/field-payloads/" + name + ".json");
}
}

View File

@@ -1,93 +0,0 @@
package org.springframework.restdocs.state;
import static java.util.Arrays.asList;
import static org.springframework.restdocs.state.FieldSnippetResultHandler.Type.REQUEST;
import static org.springframework.restdocs.state.Path.path;
import java.util.SortedSet;
import java.util.TreeSet;
import org.junit.After;
import org.junit.Test;
public class StateDocumentationValidatorTests {
SortedSet<Path> actualFields = new TreeSet<Path>();
SortedSet<Path> expectedFields = new TreeSet<Path>();
StateDocumentationValidator validator = new StateDocumentationValidator(REQUEST);
@After
public void cleanup() {
this.actualFields = new TreeSet<Path>();
this.expectedFields = new TreeSet<Path>();
}
@Test
public void equalFields() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test(expected = AssertionError.class)
public void sameLevelButMoreDocumented() {
this.actualFields = new TreeSet<Path>(asList(path("alpha")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test(expected = AssertionError.class)
public void sameLevelButMoreActuals() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test(expected = AssertionError.class)
public void moreDocumentedButParentPresent() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test
public void moreActualsButParentPresent() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("charlie"), path("charlie", "marco")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test
public void documentationSkippedLevel() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco", "alpha")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
@Test(expected = AssertionError.class)
public void moreActualsWithoutParentPresent() {
this.actualFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie")));
this.expectedFields = new TreeSet<Path>(asList(path("alpha"), path("bravo"),
path("bravo", "marco"), path("bravo", "polo"), path("charlie"),
path("charlie", "marco"), path("charlie", "marco", "alpha")));
this.validator.validateFields(this.actualFields, this.expectedFields);
}
}