From d06095ee72a20e04bdc787218ae456db3b8c4dd1 Mon Sep 17 00:00:00 2001 From: Andy Wilkinson Date: Tue, 4 Nov 2014 16:01:14 +0000 Subject: [PATCH] Provide API guide examples alongside existing getting started guides --- README.md | 5 +- .../documentation/asciidoc/api-guide.asciidoc | 404 ++++++++++++++++++ ...sciidoc => getting-started-guide.asciidoc} | 0 .../com/example/notes/ApiDocumentation.java | 297 +++++++++++++ .../documentation/asciidoc/api-guide.asciidoc | 404 ++++++++++++++++++ ...sciidoc => getting-started-guide.asciidoc} | 2 +- .../com/example/notes/ApiDocumentation.java | 293 +++++++++++++ .../com/example/notes/AbstractTagInput.java | 31 ++ .../com/example/notes/NotesController.java | 5 + .../main/java/com/example/notes/TagInput.java | 14 +- .../java/com/example/notes/TagPatchInput.java | 28 ++ .../com/example/notes/TagsController.java | 16 + 12 files changed, 1487 insertions(+), 12 deletions(-) create mode 100644 rest-notes-spring-data-rest/src/documentation/asciidoc/api-guide.asciidoc rename rest-notes-spring-data-rest/src/documentation/asciidoc/{main.asciidoc => getting-started-guide.asciidoc} (100%) create mode 100644 rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java create mode 100644 rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc rename rest-notes-spring-hateoas/src/documentation/asciidoc/{main.asciidoc => getting-started-guide.asciidoc} (99%) create mode 100644 rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java create mode 100644 rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java create mode 100644 rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java diff --git a/README.md b/README.md index 4a1aa178..fb40f2ec 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,11 @@ $ cd rest-notes-spring-data-rest $ ./gradlew restDocumentation ``` -Once the build is complete, open the generated documentation: +Once the build is complete, open on of the generated pieces HTML documentation: ``` -open build/asciidoc/main.html +open build/asciidoc/getting-started-guide.html +open build/asciidoc/api-guide.html ``` Every example request and response in the documentation is auto-generated using custom diff --git a/rest-notes-spring-data-rest/src/documentation/asciidoc/api-guide.asciidoc b/rest-notes-spring-data-rest/src/documentation/asciidoc/api-guide.asciidoc new file mode 100644 index 00000000..00708d67 --- /dev/null +++ b/rest-notes-spring-data-rest/src/documentation/asciidoc/api-guide.asciidoc @@ -0,0 +1,404 @@ += RESTful Notes API Guide +Andy Wilkinson; +:doctype: book +:toc: +:sectanchors: +:sectlinks: +:toclevels: 4 +:source-highlighter: highlightjs + +[[overview]] += Overview + +[[overview-http-verbs]] +== HTTP verbs + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview-http-status-codes]] +== HTTP status codes + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist +|=== + +[[overview-errors]] +== 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: + +|=== +| 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 +|=== + +For example, a request that attempts to apply a non-existent tag to a note will produce a +`400 Bad Request` response: + +include::{generated}/error-example/response.asciidoc[] + +[[overview-hypermedia]] +== Hypermedia + +RESTful Notes uses hypermedia and resources include links to other resources in their +responses. Responses are in http://stateless.co/hal_specification.html[Hypertext Application +Language (HAL)] format. Links can be found benath the `_links` key. Users of the API should +not created URIs themselves, instead they should use the above-described links to navigate +from resource to resource. + +[[resources]] += Resources + + + +[[resources-index]] +== Index + +The index provides the entry point into the service. + + + +[[resources-index-access]] +=== Accessing the index + +A `GET` request is used to access the index + +==== Response structure + +|=== +| JSON path | Description + +| `_links` +| <> to other resources +|=== + +==== Example response + +include::{generated}/index-example/response.asciidoc[] + + + +[[resources-index-links]] +==== Links + +|=== +| Relation | Description + +| notes +| The <> + +| tags +| The <> +|=== + + + +[[resources-notes]] +== Notes + +The Notes resources is used to create and list notes + + + +[[resources-notes-list]] +=== Listing notes + +A `GET` request will list all of the service's notes. + +==== Response structure + +|=== +| JSON path | Description + +| `_embedded.notes` +| An array of <> +|=== + +==== Example request + +include::{generated}/notes-list-example/request.asciidoc[] + +==== Example response + +include::{generated}/notes-list-example/response.asciidoc[] + + + +[[resources-notes-create]] +=== Creating a note + +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 +|=== + +==== Example request + +include::{generated}/notes-create-example/request.asciidoc[] + +==== Example response + +include::{generated}/notes-create-example/response.asciidoc[] + + + +[[resources-tags]] +== Tags + +The Tags resource is used to create and list tags. + + + +[[resources-tags-list]] +=== Listing tags + +A `GET` request will list all of the service's tags. + +==== Response structure + +|=== +| JSON path | Description + +| `_embedded.tags` +| An array of <> +|=== + +==== Example request + +include::{generated}/tags-list-example/request.asciidoc[] + +==== Example response + +include::{generated}/tags-list-example/response.asciidoc[] + + + +[[resources-tags-create]] +=== Creating a tag + +A `POST` request is used to create a note + +==== Request structure + +|=== +| JSON path | Description + +| `name` +| The name of the tag +|=== + +==== Example request + +include::{generated}/tags-create-example/request.asciidoc[] + +==== Example response + +include::{generated}/tags-create-example/response.asciidoc[] + + + +[[resources-note]] +== Note + +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 <> +|=== + + + +[[resources-note-retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +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` +| <> to other resources +|=== + + + +[[resources-note-update]] +=== Update a note + +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 +|=== + +To leave an attribute of a note unchanged, any of the above may be omitted from the request. + +==== Example request + +include::{generated}/note-update-example/request.asciidoc[] + +==== Example response + +include::{generated}/note-update-example/response.asciidoc[] + + +[[resources-note]] +== Tag + +The Tag resource is used to retrieve, update, and delete individual tags + + + +[[resources-tag-links]] +=== Links + +|=== +| Relation | Description + +| self +| This <> + +| notes +| The <> that have this tag +|=== + + + +[[resources-tag-retrieve]] +=== Retrieve a tag + +A `GET` request will retrieve the details of a tag + +Example response: + +include::{generated}/tag-get-example/response.asciidoc[] + +|=== +| JSON path | Description + +| `name` +| The name of the tag + +| `_links` +| <> to other resources +|=== + + + +[[resources-tag-update]] +=== Update a tag + +A `PATCH` request is used to update a tag + +==== Request structure + +|=== +| JSON path | Description + +| `name` +| The name of the tag + +|=== + +==== Example request + +include::{generated}/tag-update-example/request.asciidoc[] + +==== Example response + +include::{generated}/tag-update-example/response.asciidoc[] diff --git a/rest-notes-spring-data-rest/src/documentation/asciidoc/main.asciidoc b/rest-notes-spring-data-rest/src/documentation/asciidoc/getting-started-guide.asciidoc similarity index 100% rename from rest-notes-spring-data-rest/src/documentation/asciidoc/main.asciidoc rename to rest-notes-spring-data-rest/src/documentation/asciidoc/getting-started-guide.asciidoc 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 new file mode 100644 index 00000000..52b89673 --- /dev/null +++ b/rest-notes-spring-data-rest/src/documentation/java/com/example/notes/ApiDocumentation.java @@ -0,0 +1,297 @@ +/* + * 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 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.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; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.RequestDispatcher; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.restdocs.core.RestDocumentationConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = RestNotesSpringDataRest.class) +@WebAppConfiguration +public class ApiDocumentation { + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(new RestDocumentationConfiguration()).build(); + } + + @Test + public void errorExample() throws Exception { + document( + "error-example", + this.mockMvc + .perform(get("/error") + .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 400) + .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, + "/notes") + .requestAttr(RequestDispatcher.ERROR_MESSAGE, + "The tag 'http://localhost:8080/tags/123' does not exist"))) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("error", is("Bad Request"))) + .andExpect(jsonPath("timestamp", is(notNullValue()))) + .andExpect(jsonPath("status", is(400))) + .andExpect(jsonPath("path", is(notNullValue()))); + } + + @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()))); + } + + @Test + public void notesListExample() throws Exception { + this.noteRepository.deleteAll(); + + createNote("REST maturity model", + "http://martinfowler.com/articles/richardsonMaturityModel.html"); + createNote("Hypertext Application Language (HAL)", + "http://stateless.co/hal_specification.html"); + createNote("Application-Level Profile Semantics (ALPS)", "http://alps.io/spec/"); + + document("notes-list-example", this.mockMvc.perform(get("/notes"))).andExpect( + status().isOk()); + } + + @Test + public void notesCreateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + note.put("tags", Arrays.asList(tagLocation)); + + document( + "notes-create-example", + this.mockMvc.perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))).andExpect( + status().isCreated())); + } + + @Test + public void noteGetExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + note.put("tags", Arrays.asList(tagLocation)); + + String noteLocation = this.mockMvc + .perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + document("note-get-example", this.mockMvc.perform(get(noteLocation))) + .andExpect(status().isOk()) + .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()))); + + } + + @Test + public void tagsListExample() throws Exception { + this.noteRepository.deleteAll(); + this.tagRepository.deleteAll(); + + createTag("REST"); + createTag("Hypermedia"); + createTag("HTTP"); + + document("tags-list-example", this.mockMvc.perform(get("/tags"))).andExpect( + status().isOk()); + } + + @Test + public void tagsCreateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + document( + "tags-create-example", + this.mockMvc.perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))).andExpect( + status().isCreated())); + } + + @Test + public void noteUpdateExample() throws Exception { + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + + String noteLocation = this.mockMvc + .perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + this.mockMvc.perform(get(noteLocation)).andExpect(status().isOk()) + .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()))); + + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map noteUpdate = new HashMap(); + noteUpdate.put("tags", Arrays.asList(tagLocation)); + + document( + "note-update-example", + this.mockMvc.perform( + patch(noteLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(noteUpdate))) + .andExpect(status().isNoContent())); + } + + @Test + public void tagGetExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + 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()))); + + } + + @Test + public void tagUpdateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map tagUpdate = new HashMap(); + tagUpdate.put("name", "RESTful"); + + document( + "tag-update-example", + this.mockMvc.perform( + patch(tagLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tagUpdate))) + .andExpect(status().isNoContent())); + } + + private void createNote(String title, String body) { + Note note = new Note(); + note.setTitle(title); + note.setBody(body); + + this.noteRepository.save(note); + } + + private void createTag(String name) { + Tag tag = new Tag(); + tag.setName(name); + this.tagRepository.save(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 new file mode 100644 index 00000000..18bd7701 --- /dev/null +++ b/rest-notes-spring-hateoas/src/documentation/asciidoc/api-guide.asciidoc @@ -0,0 +1,404 @@ += RESTful Notes API Guide +Andy Wilkinson; +:doctype: book +:toc: +:sectanchors: +:sectlinks: +:toclevels: 4 +:source-highlighter: highlightjs + +[[overview]] += Overview + +[[overview-http-verbs]] +== HTTP verbs + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP verbs. + +|=== +| Verb | Usage + +| `GET` +| Used to retrieve a resource + +| `POST` +| Used to create a new resource + +| `PATCH` +| Used to update an existing resource, including partial updates + +| `DELETE` +| Used to delete an existing resource +|=== + +[[overview-http-status-codes]] +== HTTP status codes + +RESTful notes tries to adhere as closely as possible to standard HTTP and REST conventions in its +use of HTTP status codes. + +|=== +| Status code | Usage + +| `200 OK` +| The request completed successfully + +| `201 Created` +| A new resource has been created successfully. The resource's URI is available from the response's +`Location` header + +| `204 No Content` +| An update to an existing resource has been applied successfully + +| `400 Bad Request` +| The request was malformed. The response body will include an error providing further information + +| `404 Not Found` +| The requested resource did not exist +|=== + +[[overview-errors]] +== 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: + +|=== +| 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 +|=== + +For example, a request that attempts to apply a non-existent tag to a note will produce a +`400 Bad Request` response: + +include::{generated}/error-example/response.asciidoc[] + +[[overview-hypermedia]] +== Hypermedia + +RESTful Notes uses hypermedia and resources include links to other resources in their +responses. Responses are in http://stateless.co/hal_specification.html[Hypertext Application +Language (HAL)] format. Links can be found benath the `_links` key. Users of the API should +not created URIs themselves, instead they should use the above-described links to navigate +from resource to resource. + +[[resources]] += Resources + + + +[[resources-index]] +== Index + +The index provides the entry point into the service. + + + +[[resources-index-access]] +=== Accessing the index + +A `GET` request is used to access the index + +==== Response structure + +|=== +| JSON path | Description + +| `_links` +| <> to other resources +|=== + +==== Example response + +include::{generated}/index-example/response.asciidoc[] + + + +[[resources-index-links]] +==== Links + +|=== +| Relation | Description + +| notes +| The <> + +| tags +| The <> +|=== + + + +[[resources-notes]] +== Notes + +The Notes resources is used to create and list notes + + + +[[resources-notes-list]] +=== Listing notes + +A `GET` request will list all of the service's notes. + +==== Response structure + +|=== +| JSON path | Description + +| `_embedded.notes` +| An array of <> +|=== + +==== Example request + +include::{generated}/notes-list-example/request.asciidoc[] + +==== Example response + +include::{generated}/notes-list-example/response.asciidoc[] + + + +[[resources-notes-create]] +=== Creating a note + +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 +|=== + +==== Example request + +include::{generated}/notes-create-example/request.asciidoc[] + +==== Example response + +include::{generated}/notes-create-example/response.asciidoc[] + + + +[[resources-tags]] +== Tags + +The Tags resource is used to create and list tags. + + + +[[resources-tags-list]] +=== Listing tags + +A `GET` request will list all of the service's tags. + +==== Response structure + +|=== +| JSON path | Description + +| `_embedded.tags` +| An array of <> +|=== + +==== Example request + +include::{generated}/tags-list-example/request.asciidoc[] + +==== Example response + +include::{generated}/tags-list-example/response.asciidoc[] + + + +[[resources-tags-create]] +=== Creating a tag + +A `POST` request is used to create a note + +==== Request structure + +|=== +| JSON path | Description + +| `name` +| The name of the tag +|=== + +==== Example request + +include::{generated}/tags-create-example/request.asciidoc[] + +==== Example response + +include::{generated}/tags-create-example/response.asciidoc[] + + + +[[resources-note]] +== Note + +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 <> +|=== + + + +[[resources-note-retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +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` +| <> to other resources +|=== + + + +[[resources-note-update]] +=== Update a note + +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 +|=== + +To leave an attribute of a note unchanged, any of the above may be omitted from the request. + +==== Example request + +include::{generated}/note-update-example/request.asciidoc[] + +==== Example response + +include::{generated}/note-update-example/response.asciidoc[] + + +[[resources-note]] +== Tag + +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 +|=== + + + +[[resources-tag-retrieve]] +=== Retrieve a tag + +A `GET` request will retrieve the details of a tag + +Example response: + +include::{generated}/tag-get-example/response.asciidoc[] + +|=== +| JSON path | Description + +| `name` +| The name of the tag + +| `_links` +| <> to other resources +|=== + + + +[[resources-tag-update]] +=== Update a tag + +A `PATCH` request is used to update a tag + +==== Request structure + +|=== +| JSON path | Description + +| `name` +| The name of the tag + +|=== + +==== Example request + +include::{generated}/tag-update-example/request.asciidoc[] + +==== Example response + +include::{generated}/tag-update-example/response.asciidoc[] diff --git a/rest-notes-spring-hateoas/src/documentation/asciidoc/main.asciidoc b/rest-notes-spring-hateoas/src/documentation/asciidoc/getting-started-guide.asciidoc similarity index 99% rename from rest-notes-spring-hateoas/src/documentation/asciidoc/main.asciidoc rename to rest-notes-spring-hateoas/src/documentation/asciidoc/getting-started-guide.asciidoc index 6ac321c1..ce970ea9 100644 --- a/rest-notes-spring-hateoas/src/documentation/asciidoc/main.asciidoc +++ b/rest-notes-spring-hateoas/src/documentation/asciidoc/getting-started-guide.asciidoc @@ -1,4 +1,4 @@ -= RESTful Notes User Guide += RESTful Notes Getting Started Guide Andy Wilkinson; :doctype: book :toc: 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 new file mode 100644 index 00000000..04ba5f4d --- /dev/null +++ b/rest-notes-spring-hateoas/src/documentation/java/com/example/notes/ApiDocumentation.java @@ -0,0 +1,293 @@ +/* + * 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 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.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; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.RequestDispatcher; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.SpringApplicationConfiguration; +import org.springframework.hateoas.MediaTypes; +import org.springframework.restdocs.core.RestDocumentationConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import org.springframework.test.context.web.WebAppConfiguration; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; + +@RunWith(SpringJUnit4ClassRunner.class) +@SpringApplicationConfiguration(classes = RestNotesSpringHateoas.class) +@WebAppConfiguration +public class ApiDocumentation { + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @Before + public void setUp() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(new RestDocumentationConfiguration()).build(); + } + + @Test + public void errorExample() throws Exception { + document( + "error-example", + this.mockMvc + .perform(get("/error") + .requestAttr(RequestDispatcher.ERROR_STATUS_CODE, 400) + .requestAttr(RequestDispatcher.ERROR_REQUEST_URI, + "/notes") + .requestAttr(RequestDispatcher.ERROR_MESSAGE, + "The tag 'http://localhost:8080/tags/123' does not exist"))) + .andDo(print()).andExpect(status().isBadRequest()) + .andExpect(jsonPath("error", is("Bad Request"))) + .andExpect(jsonPath("timestamp", is(notNullValue()))) + .andExpect(jsonPath("status", is(400))) + .andExpect(jsonPath("path", is(notNullValue()))); + } + + @Test + public void indexExample() throws Exception { + document("index-example", + this.mockMvc.perform(get("/")).andExpect(status().isOk())); + } + + @Test + public void notesListExample() throws Exception { + this.noteRepository.deleteAll(); + + createNote("REST maturity model", + "http://martinfowler.com/articles/richardsonMaturityModel.html"); + createNote("Hypertext Application Language (HAL)", + "http://stateless.co/hal_specification.html"); + createNote("Application-Level Profile Semantics (ALPS)", "http://alps.io/spec/"); + + document("notes-list-example", this.mockMvc.perform(get("/notes"))).andExpect( + status().isOk()); + } + + @Test + public void notesCreateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + note.put("tags", Arrays.asList(tagLocation)); + + document( + "notes-create-example", + this.mockMvc.perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))).andExpect( + status().isCreated())); + } + + @Test + public void noteGetExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + note.put("tags", Arrays.asList(tagLocation)); + + String noteLocation = this.mockMvc + .perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + document("note-get-example", this.mockMvc.perform(get(noteLocation))) + .andExpect(status().isOk()) + .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()))); + + } + + @Test + public void tagsListExample() throws Exception { + this.noteRepository.deleteAll(); + this.tagRepository.deleteAll(); + + createTag("REST"); + createTag("Hypermedia"); + createTag("HTTP"); + + document("tags-list-example", this.mockMvc.perform(get("/tags"))).andExpect( + status().isOk()); + } + + @Test + public void tagsCreateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + document( + "tags-create-example", + this.mockMvc.perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))).andExpect( + status().isCreated())); + } + + @Test + public void noteUpdateExample() throws Exception { + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html"); + + String noteLocation = this.mockMvc + .perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + this.mockMvc.perform(get(noteLocation)).andExpect(status().isOk()) + .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()))); + + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map noteUpdate = new HashMap(); + noteUpdate.put("tags", Arrays.asList(tagLocation)); + + document( + "note-update-example", + this.mockMvc.perform( + patch(noteLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(noteUpdate))) + .andExpect(status().isNoContent())); + } + + @Test + public void tagGetExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + 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()))); + + } + + @Test + public void tagUpdateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + String tagLocation = this.mockMvc + .perform( + post("/tags").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()).andReturn().getResponse() + .getHeader("Location"); + + Map tagUpdate = new HashMap(); + tagUpdate.put("name", "RESTful"); + + document( + "tag-update-example", + this.mockMvc.perform( + patch(tagLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tagUpdate))) + .andExpect(status().isNoContent())); + } + + private void createNote(String title, String body) { + Note note = new Note(); + note.setTitle(title); + note.setBody(body); + + this.noteRepository.save(note); + } + + private void createTag(String name) { + Tag tag = new Tag(); + tag.setName(name); + this.tagRepository.save(tag); + } +} diff --git a/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java b/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java new file mode 100644 index 00000000..824ce43b --- /dev/null +++ b/rest-notes-spring-hateoas/src/main/java/com/example/notes/AbstractTagInput.java @@ -0,0 +1,31 @@ +/* + * 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 com.example.notes; + + +abstract class AbstractTagInput { + + private final String name; + + public AbstractTagInput(String name) { + this.name = name; + } + + public String getName() { + return name; + } +} diff --git a/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java b/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java index f937c2a0..2ff6c5f1 100644 --- a/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java +++ b/rest-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java @@ -85,6 +85,11 @@ public class NotesController { return httpHeaders; } + @RequestMapping(value = "/{id}", method = RequestMethod.DELETE) + void delete(@PathVariable("id") long id) { + this.noteRepository.delete(id); + } + @RequestMapping(value = "/{id}", method = RequestMethod.GET) Resource note(@PathVariable("id") long id) { Note note = this.noteRepository.findById(id).orElseThrow( diff --git a/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java index b50718a4..8c3c3d39 100644 --- a/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java +++ b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java @@ -16,19 +16,15 @@ package com.example.notes; +import org.hibernate.validator.constraints.NotBlank; + import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; -public class TagInput { - - private final String name; +public class TagInput extends AbstractTagInput { @JsonCreator - public TagInput(@JsonProperty("name") String name) { - this.name = name; - } - - public String getName() { - return name; + public TagInput(@NotBlank @JsonProperty("name") String name) { + super(name); } } diff --git a/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java new file mode 100644 index 00000000..254a7886 --- /dev/null +++ b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java @@ -0,0 +1,28 @@ +/* + * 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 com.example.notes; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class TagPatchInput extends AbstractTagInput { + + @JsonCreator + public TagPatchInput(@NullOrNotBlank @JsonProperty("name") String name) { + super(name); + } +} diff --git a/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java index 8af0dca2..f29867c5 100644 --- a/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java +++ b/rest-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java @@ -72,6 +72,11 @@ public class TagsController { return httpHeaders; } + @RequestMapping(value = "/{id}", method = RequestMethod.DELETE) + void delete(@PathVariable("id") long id) { + this.repository.delete(id); + } + @RequestMapping(value = "/{id}", method = RequestMethod.GET) Resource tag(@PathVariable("id") long id) { Tag tag = this.repository.findById(id).orElseThrow( @@ -86,4 +91,15 @@ public class TagsController { .orElseThrow(() -> new ResourceDoesNotExistException()) .getNotes())); } + + @RequestMapping(value = "/{id}", method = RequestMethod.PATCH) + @ResponseStatus(HttpStatus.NO_CONTENT) + void updateTag(@PathVariable("id") long id, @RequestBody TagPatchInput tagInput) { + Tag tag = this.repository.findById(id).orElseThrow( + () -> new ResourceDoesNotExistException()); + if (tagInput.getName() != null) { + tag.setName(tagInput.getName()); + } + this.repository.save(tag); + } }