Improve code structure, javadoc, and build configuration

This commit makes extensive changes to the structure of the code. It
introduces a number of separate packages to provide better separation
of the various areas of functionality. Alongside this change the project
has been renamed from spring-restdocs-core to spring-restdocs.

The build has been improved to provide support for building the samples
from the main build using the buildSamples task. While this change has
been made, the samples remain standalone projects so that their
configuration is not dependent on the main project’s build. Running
buildSamples will build the samples using both Maven and Gradle.

All of the main project’s classes now have javadoc and licence/copyright
headers.
This commit is contained in:
Andy Wilkinson
2015-02-16 16:46:56 +00:00
parent f405848179
commit 42270b127c
89 changed files with 1137 additions and 533 deletions

View File

@@ -0,0 +1,380 @@
= 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`
| <<resources-index-links,Links>> to other resources
|===
==== Example response
include::{generated}/index-example/response.asciidoc[]
[[resources-index-links]]
==== Links
include::{generated}/index-example/links.asciidoc[]
[[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 <<resources-note,Note resources>>
|===
==== 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 <<resources-tag,Tag resources>>
|===
==== 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
include::{generated}/note-get-example/links.asciidoc[]
[[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`
| <<resources-note-links,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
include::{generated}/tag-get-example/links.asciidoc[]
[[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`
| <<resources-tag-links,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[]

View File

@@ -0,0 +1,174 @@
= RESTful Notes Getting Started Guide
Andy Wilkinson;
:doctype: book
:toc:
:toclevels: 4
:source-highlighter: highlightjs
[introduction]
= Introduction
RESTful Notes is a RESTful web service for creating and storing notes. It uses hypermedia
to describe the relationships between resources and to allow navigation between them.
[getting-started]
= Getting started
[getting-started-running-the-service]
== Running the service
RESTful Notes is written using http://projects.spring.io/spring-boot[Spring Boot] which
makes it easy to get it up and running so that you can start exploring the REST API.
At the moment, binaries for RESTful Notes are not published anywhere. However, building
and running it from source is straightforward. The first step is to clone the Git
repository:
[source,bash]
----
$ git clone https://github.com/wilkinsona/spring-restdocs
----
Once the clone is complete, you're ready to get the service up and running:
[source,bash]
----
$ cd rest-notes
$ ./gradlew build
$ java -jar build/libs/*.jar
----
You can check that the service is up and running by executing a simple request using
cURL:
include::{generated}/index/request.asciidoc[]
This request should yield the following response:
include::{generated}/index/response.asciidoc[]
Note the `_links` in the JSON response. They are key to navigating the API.
[getting-started-creating-a-note]
== Creating a note
Now that you've started the service and verified that it works, the next step is to use
it to create a new note. As you saw above, the URI for working with notes is included as
a link when you perform a `GET` request against the root of the service:
include::{generated}/index/response.asciidoc[]
To create a note you need to execute a `POST` request to this URI, including a JSON
payload containing the title and body of the note:
include::{generated}/create-note/request.asciidoc[]
The response from this request should have a status code of `201 Created` and contain a
`Location` header whose value is the URI of the newly created note:
include::{generated}/create-note/response.asciidoc[]
To work with the newly created note you use the URI in the `Location` header. For example
you can access the note's details by performing a `GET` request:
include::{generated}/get-note/request.asciidoc[]
This request will produce a response with the note's details in its body:
include::{generated}/get-note/response.asciidoc[]
Note the `note-tags` link which we'll make use of later.
[getting-started-creating-a-tag]
== Creating a tag
To make a note easier to find, it can be associated with any number of tags. To be able
to tag a note, you must first create the tag.
Referring back to the response for the service's index, the URI for working with tags is
include as a link:
include::{generated}/index/response.asciidoc[]
To create a tag you need to execute a `POST` request to this URI, including a JSON
payload containing the name of the tag:
include::{generated}/create-tag/request.asciidoc[]
The response from this request should have a status code of `201 Created` and contain a
`Location` header whose value is the URI of the newly created tag:
include::{generated}/create-tag/response.asciidoc[]
To work with the newly created tag you use the URI in the `Location` header. For example
you can access the tag's details by performing a `GET` request:
include::{generated}/get-tag/request.asciidoc[]
This request will produce a response with the tag's details in its body:
include::{generated}/get-tag/response.asciidoc[]
[getting-started-tagging-a-note]
== Tagging a note
A tag isn't particularly useful until it's been associated with one or more notes. There
are two ways to tag a note: when the note is first created or by updating an existing
note. We'll look at both of these in turn.
[getting-started-tagging-a-note-creating]
=== Creating a tagged note
The process is largely the same as we saw before, but this time, in addition to providing
a title and body for the note, we'll also provide the tag that we want to be associated
with it.
Once again we execute a `POST` request, but this time, in an array named tags, we include
the URI of the tag we just created:
include::{generated}/create-tagged-note/request.asciidoc[]
Once again, the response's `Location` header tells use the URI of the newly created note:
include::{generated}/create-tagged-note/response.asciidoc[]
As before, a `GET` request executed against this URI will retrieve the note's details:
include::{generated}/get-tagged-note/request-response.asciidoc[]
To see the note's tags, execute a `GET` request against the URI of the note's
`note-tags` link:
include::{generated}/get-tags/request.asciidoc[]
The response shows that, as expected, the note has a single tag:
include::{generated}/get-tags/response.asciidoc[]
[getting-started-tagging-a-note-existing]
=== Tagging an existing note
An existing note can be tagged by executing a `PATCH` request against the note's URI with
a body that contains the array of tags to be associated with the note. We'll used the
URI of the untagged note that we created earlier:
include::{generated}/tag-existing-note/request.asciidoc[]
This request should produce a `204 No Content` response:
include::{generated}/tag-existing-note/response.asciidoc[]
When we first created this note, we noted the `note-tags` link included in its details:
include::{generated}/get-note/response.asciidoc[]
We can use that link now and execute a `GET` request to see that the note now has a
single tag:
include::{generated}/get-tags-for-existing-note/request-response.asciidoc[]

View File

@@ -0,0 +1,51 @@
/*
* 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 java.net.URI;
import java.util.Collections;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonProperty;
abstract class AbstractNoteInput {
private final String title;
private final String body;
private final List<URI> tagUris;
public AbstractNoteInput(String title, String body, List<URI> tagUris) {
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI> emptyList() : tagUris;
}
public String getTitle() {
return title;
}
public String getBody() {
return body;
}
@JsonProperty("tags")
public List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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 java.util.Map;
import org.springframework.boot.autoconfigure.web.DefaultErrorAttributes;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
@Component
class ExceptionSupressingErrorAttributes extends DefaultErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(RequestAttributes requestAttributes,
boolean includeStackTrace) {
Map<String, Object> errorAttributes = super.getErrorAttributes(requestAttributes, includeStackTrace);
errorAttributes.remove("exception");
Object message = requestAttributes.getAttribute("javax.servlet.error.message", RequestAttributes.SCOPE_REQUEST);
if (message != null) {
errorAttributes.put("message", message);
}
return errorAttributes;
}
}

View File

@@ -0,0 +1,38 @@
/*
* 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.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/")
public class IndexController {
@RequestMapping(method=RequestMethod.GET)
public ResourceSupport index() {
ResourceSupport index = new ResourceSupport();
index.add(linkTo(NotesController.class).withRel("notes"));
index.add(linkTo(TagsController.class).withRel("tags"));
return index;
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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 org.springframework.hateoas.ResourceSupport;
import org.springframework.hateoas.Resources;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
public class NestedContentResource<T> extends ResourceSupport {
private final Resources<T> nested;
public NestedContentResource(Iterable<T> toNest) {
this.nested = new Resources<T>(toNest);
}
@JsonUnwrapped
public Resources<T> getNested() {
return this.nested;
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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 java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity
public class Note {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String title;
private String body;
@ManyToMany
private List<Tag> tags;
@JsonIgnore
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getBody() {
return body;
}
public void setBody(String body) {
this.body = body;
}
@JsonIgnore
public List<Tag> getTags() {
return tags;
}
public void setTags(List<Tag> tags) {
this.tags = tags;
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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 java.net.URI;
import java.util.List;
import org.hibernate.validator.constraints.NotBlank;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class NoteInput extends AbstractNoteInput {
@JsonCreator
public NoteInput(@NotBlank @JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
super(title, body, tagUris);
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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 java.net.URI;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class NotePatchInput extends AbstractNoteInput {
@JsonCreator
public NotePatchInput(@NullOrNotBlank @JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
super(title, body, tagUris);
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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 java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.repository.CrudRepository;
public interface NoteRepository extends CrudRepository<Note, Long> {
Optional<Note> findById(long id);
List<Note> findByTagsIn(Collection<Tag> tags);
}

View File

@@ -0,0 +1,54 @@
/*
* 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.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import org.springframework.stereotype.Component;
import com.example.notes.NoteResourceAssembler.NoteResource;
@Component
public class NoteResourceAssembler extends ResourceAssemblerSupport<Note, NoteResource> {
public NoteResourceAssembler() {
super(NotesController.class, NoteResource.class);
}
@Override
public NoteResource toResource(Note note) {
NoteResource resource = createResourceWithId(note.getId(), note);
resource.add(linkTo(NotesController.class).slash(note.getId()).slash("tags")
.withRel("note-tags"));
return resource;
}
@Override
protected NoteResource instantiateResource(Note entity) {
return new NoteResource(entity);
}
static class NoteResource extends Resource<Note> {
public NoteResource(Note content) {
super(content);
}
}
}

View File

@@ -0,0 +1,146 @@
/*
* 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.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import java.net.URI;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.UriTemplate;
import com.example.notes.NoteResourceAssembler.NoteResource;
import com.example.notes.TagResourceAssembler.TagResource;
@RestController
@RequestMapping("/notes")
public class NotesController {
private static final UriTemplate TAG_URI_TEMPLATE = new UriTemplate("/tags/{id}");
private final NoteRepository noteRepository;
private final TagRepository tagRepository;
private final NoteResourceAssembler noteResourceAssembler;
private final TagResourceAssembler tagResourceAssembler;
@Autowired
public NotesController(NoteRepository noteRepository, TagRepository tagRepository,
NoteResourceAssembler noteResourceAssembler,
TagResourceAssembler tagResourceAssembler) {
this.noteRepository = noteRepository;
this.tagRepository = tagRepository;
this.noteResourceAssembler = noteResourceAssembler;
this.tagResourceAssembler = tagResourceAssembler;
}
@RequestMapping(method = RequestMethod.GET)
NestedContentResource<NoteResource> all() {
return new NestedContentResource<NoteResource>(
this.noteResourceAssembler.toResources(this.noteRepository.findAll()));
}
@ResponseStatus(HttpStatus.CREATED)
@RequestMapping(method = RequestMethod.POST)
HttpHeaders create(@RequestBody NoteInput noteInput) {
Note note = new Note();
note.setTitle(noteInput.getTitle());
note.setBody(noteInput.getBody());
note.setTags(getTags(noteInput.getTagUris()));
this.noteRepository.save(note);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders
.setLocation(linkTo(NotesController.class).slash(note.getId()).toUri());
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> note(@PathVariable("id") long id) {
Note note = this.noteRepository.findById(id).orElseThrow(
() -> new ResourceDoesNotExistException());
return this.noteResourceAssembler.toResource(note);
}
@RequestMapping(value = "/{id}/tags", method = RequestMethod.GET)
ResourceSupport noteTags(@PathVariable("id") long id) {
return new NestedContentResource<TagResource>(
this.tagResourceAssembler.toResources(this.noteRepository
.findById(id)
.orElseThrow(
() -> new ResourceDoesNotExistException()).getTags()));
}
@RequestMapping(value = "/{id}", method = RequestMethod.PATCH)
@ResponseStatus(HttpStatus.NO_CONTENT)
void updateNote(@PathVariable("id") long id, @RequestBody NotePatchInput noteInput) {
Note note = this.noteRepository.findById(id).orElseThrow(
() -> new ResourceDoesNotExistException());
if (noteInput.getTagUris() != null) {
note.setTags(getTags(noteInput.getTagUris()));
}
if (noteInput.getTitle() != null) {
note.setTitle(noteInput.getTitle());
}
if (noteInput.getBody() != null) {
note.setBody(noteInput.getBody());
}
this.noteRepository.save(note);
}
private List<Tag> getTags(List<URI> tagLocations) {
return tagLocations
.stream()
.map(location -> this.tagRepository.findById(extractTagId(location))
.<IllegalArgumentException> orElseThrow(
() -> new IllegalArgumentException("The tag '" + location
+ "' does not exist")))
.collect(Collectors.toList());
}
private long extractTagId(URI tagLocation) {
try {
String idString = TAG_URI_TEMPLATE.match(tagLocation.toASCIIString()).get(
"id");
return Long.valueOf(idString);
}
catch (RuntimeException ex) {
throw new IllegalArgumentException("The tag '" + tagLocation + "' is invalid");
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* 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 java.lang.annotation.ElementType;
import java.lang.annotation.Target;
import javax.validation.constraints.NotNull;
import org.hibernate.validator.constraints.CompositionType;
import org.hibernate.validator.constraints.ConstraintComposition;
import org.hibernate.validator.constraints.NotBlank;
@ConstraintComposition(CompositionType.OR)
@NotNull
@NotBlank
@Target({ ElementType.FIELD, ElementType.PARAMETER })
public @interface NullOrNotBlank {
}

View File

@@ -0,0 +1,22 @@
/*
* 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;
@SuppressWarnings("serial")
public class ResourceDoesNotExistException extends RuntimeException {
}

View File

@@ -0,0 +1,44 @@
/*
* 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 java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
@ControllerAdvice
public class RestNotesControllerAdvice {
@ExceptionHandler(IllegalArgumentException.class)
public void handleIllegalArgumentException(IllegalArgumentException ex,
HttpServletResponse response) throws IOException {
response.sendError(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
}
@ExceptionHandler(ResourceDoesNotExistException.class)
public void handleResourceDoesNotExistException(ResourceDoesNotExistException ex,
HttpServletRequest request, HttpServletResponse response) throws IOException {
response.sendError(HttpStatus.NOT_FOUND.value(),
"The resource '" + request.getRequestURI() + "' does not exist");
}
}

View File

@@ -0,0 +1,33 @@
/*
* 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 org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@EnableAutoConfiguration
@ComponentScan
@EnableJpaRepositories
public class RestNotesSpringHateoas {
public static void main(String[] args) {
SpringApplication.run(RestNotesSpringHateoas.class, args);
}
}

View File

@@ -0,0 +1,66 @@
/*
* 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 java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import com.fasterxml.jackson.annotation.JsonIgnore;
@Entity
public class Tag {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String name;
@ManyToMany(mappedBy = "tags")
private List<Note> notes;
@JsonIgnore
public long getId() {
return id;
}
public void setId(long id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
@JsonIgnore
public List<Note> getNotes() {
return notes;
}
public void setNotes(List<Note> notes) {
this.notes = notes;
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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 org.hibernate.validator.constraints.NotBlank;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
public class TagInput extends AbstractTagInput {
@JsonCreator
public TagInput(@NotBlank @JsonProperty("name") String name) {
super(name);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,27 @@
/*
* 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 java.util.Optional;
import org.springframework.data.repository.CrudRepository;
public interface TagRepository extends CrudRepository<Tag, Long> {
Optional<Tag> findById(long id);
}

View File

@@ -0,0 +1,54 @@
/*
* 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.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import org.springframework.stereotype.Component;
import com.example.notes.TagResourceAssembler.TagResource;
@Component
public class TagResourceAssembler extends ResourceAssemblerSupport<Tag, TagResource> {
public TagResourceAssembler() {
super(TagsController.class, TagResource.class);
}
@Override
public TagResource toResource(Tag tag) {
TagResource resource = createResourceWithId(tag.getId(), tag);
resource.add(linkTo(TagsController.class).slash(tag.getId()).slash("notes")
.withRel("tagged-notes"));
return resource;
}
@Override
protected TagResource instantiateResource(Tag entity) {
return new TagResource(entity);
}
static class TagResource extends Resource<Tag> {
public TagResource(Tag content) {
super(content);
}
}
}

View File

@@ -0,0 +1,105 @@
/*
* 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.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceSupport;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import com.example.notes.NoteResourceAssembler.NoteResource;
import com.example.notes.TagResourceAssembler.TagResource;
@RestController
@RequestMapping("tags")
public class TagsController {
private final TagRepository repository;
private final NoteResourceAssembler noteResourceAssembler;
private final TagResourceAssembler tagResourceAssembler;
@Autowired
public TagsController(TagRepository repository,
NoteResourceAssembler noteResourceAssembler,
TagResourceAssembler tagResourceAssembler) {
this.repository = repository;
this.noteResourceAssembler = noteResourceAssembler;
this.tagResourceAssembler = tagResourceAssembler;
}
@RequestMapping(method = RequestMethod.GET)
NestedContentResource<TagResource> all() {
return new NestedContentResource<TagResource>(
this.tagResourceAssembler.toResources(this.repository.findAll()));
}
@ResponseStatus(HttpStatus.CREATED)
@RequestMapping(method = RequestMethod.POST)
HttpHeaders create(@RequestBody TagInput tagInput) {
Tag tag = new Tag();
tag.setName(tagInput.getName());
this.repository.save(tag);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.setLocation(linkTo(TagsController.class).slash(tag.getId()).toUri());
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> tag(@PathVariable("id") long id) {
Tag tag = this.repository.findById(id).orElseThrow(
() -> new ResourceDoesNotExistException());
return this.tagResourceAssembler.toResource(tag);
}
@RequestMapping(value = "/{id}/notes", method = RequestMethod.GET)
ResourceSupport tagNotes(@PathVariable("id") long id) {
return new NestedContentResource<NoteResource>(
this.noteResourceAssembler.toResources(this.repository.findById(id)
.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);
}
}

View File

@@ -0,0 +1 @@
http.mappers.json-pretty-print: true

View File

@@ -0,0 +1,302 @@
/*
* 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 com.example.notes;
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.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.RestDocumentationConfigurer;
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 RestDocumentationConfigurer()).build();
}
@Test
public void errorExample() throws Exception {
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())))
.andDo(document("error-example"));
}
@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>>")));
}
@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/");
this.mockMvc.perform(get("/notes"))
.andExpect(status().isOk())
.andDo(document("notes-list-example"));
}
@Test
public void notesCreateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
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<String, Object> note = new HashMap<String, Object>();
note.put("title", "REST maturity model");
note.put("body", "http://martinfowler.com/articles/richardsonMaturityModel.html");
note.put("tags", Arrays.asList(tagLocation));
this.mockMvc.perform(
post("/notes").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andDo(document("notes-create-example"));
}
@Test
public void noteGetExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
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<String, Object> note = new HashMap<String, Object>();
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");
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())))
.andDo(document("note-get-example").withLinks(
linkWithRel("self").description("This <<resources-note,note>>"),
linkWithRel("note-tags").description(
"This note's <<resources-note-tags,tags>>")));
}
@Test
public void tagsListExample() throws Exception {
this.noteRepository.deleteAll();
this.tagRepository.deleteAll();
createTag("REST");
createTag("Hypermedia");
createTag("HTTP");
this.mockMvc.perform(get("/tags"))
.andExpect(status().isOk())
.andDo(document("tags-list-example"));
}
@Test
public void tagsCreateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
this.mockMvc.perform(
post("/tags").contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andDo(document("tags-create-example"));
}
@Test
public void noteUpdateExample() throws Exception {
Map<String, Object> note = new HashMap<String, Object>();
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<String, String> tag = new HashMap<String, String>();
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<String, Object> noteUpdate = new HashMap<String, Object>();
noteUpdate.put("tags", Arrays.asList(tagLocation));
this.mockMvc.perform(
patch(noteLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(noteUpdate)))
.andExpect(status().isNoContent())
.andDo(document("note-update-example"));
}
@Test
public void tagGetExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
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");
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")));
}
@Test
public void tagUpdateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
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<String, Object> tagUpdate = new HashMap<String, Object>();
tagUpdate.put("name", "RESTful");
this.mockMvc.perform(
patch(tagLocation).contentType(MediaTypes.HAL_JSON).content(
this.objectMapper.writeValueAsString(tagUpdate)))
.andExpect(status().isNoContent())
.andDo(document("tag-update-example"));
}
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);
}
}

View File

@@ -0,0 +1,213 @@
/*
* 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.hasSize;
import static org.hamcrest.Matchers.is;
import static org.hamcrest.Matchers.notNullValue;
import static org.springframework.restdocs.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.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.io.UnsupportedEncodingException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
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.RestDocumentationConfigurer;
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.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.jayway.jsonpath.JsonPath;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = RestNotesSpringHateoas.class)
@WebAppConfiguration
public class GettingStartedDocumentation {
@Autowired
private ObjectMapper objectMapper;
@Autowired
private WebApplicationContext context;
private MockMvc mockMvc;
@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
.apply(new RestDocumentationConfigurer()).build();
}
@Test
public void index() throws Exception {
this.mockMvc.perform(get("/").accept(MediaTypes.HAL_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("_links.notes", is(notNullValue())))
.andExpect(jsonPath("_links.tags", is(notNullValue())))
.andDo(document("index"));
}
@Test
public void creatingANote() throws JsonProcessingException, Exception {
String noteLocation = createNote();
getNote(noteLocation);
String tagLocation = createTag();
getTag(tagLocation);
String taggedNoteLocation = createTaggedNote(tagLocation);
getTaggedNote(taggedNoteLocation);
getTags(taggedNoteLocation);
tagExistingNote(noteLocation, tagLocation);
getTaggedExistingNote(noteLocation);
getTagsForExistingNote(noteLocation);
}
String createNote() throws Exception {
Map<String, String> note = new HashMap<String, String>();
note.put("title", "Note creation with cURL");
note.put("body", "An example of how to create a note using cURL");
String noteLocation = this.mockMvc
.perform(
post("/notes").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.andDo(document("create-note"))
.andReturn().getResponse().getHeader("Location");
return noteLocation;
}
void getNote(String noteLocation) throws Exception {
this.mockMvc.perform(get(noteLocation)).andExpect(status().isOk())
.andExpect(jsonPath("title", is(notNullValue())))
.andExpect(jsonPath("body", is(notNullValue())))
.andExpect(jsonPath("_links.note-tags", is(notNullValue())))
.andDo(document("get-note"));
}
String createTag() throws Exception, JsonProcessingException {
Map<String, String> tag = new HashMap<String, String>();
tag.put("name", "getting-started");
String tagLocation = this.mockMvc
.perform(
post("/tags").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.andDo(document("create-tag"))
.andReturn().getResponse().getHeader("Location");
return tagLocation;
}
void getTag(String tagLocation) throws Exception {
this.mockMvc.perform(get(tagLocation)).andExpect(status().isOk())
.andExpect(jsonPath("name", is(notNullValue())))
.andExpect(jsonPath("_links.tagged-notes", is(notNullValue())))
.andDo(document("get-tag"));
}
String createTaggedNote(String tag) throws Exception {
Map<String, Object> note = new HashMap<String, Object>();
note.put("title", "Tagged note creation with cURL");
note.put("body", "An example of how to create a tagged note using cURL");
note.put("tags", Arrays.asList(tag));
String noteLocation = this.mockMvc
.perform(
post("/notes").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.andDo(document("create-tagged-note"))
.andReturn().getResponse().getHeader("Location");
return noteLocation;
}
void getTaggedNote(String tagLocation) throws Exception {
this.mockMvc.perform(get(tagLocation))
.andExpect(status().isOk())
.andExpect(jsonPath("title", is(notNullValue())))
.andExpect(jsonPath("body", is(notNullValue())))
.andExpect(jsonPath("_links.note-tags", is(notNullValue())))
.andDo(document("get-tagged-note"));
}
void getTags(String taggedNoteLocation) throws Exception {
String tagsLocation = getLink(this.mockMvc.perform(get(taggedNoteLocation))
.andReturn(), "note-tags");
this.mockMvc.perform(get(tagsLocation))
.andExpect(status().isOk())
.andDo(print())
.andExpect(jsonPath("_embedded.tags", hasSize(1)))
.andDo(document("get-tags"));
}
void tagExistingNote(String noteLocation, String tagLocation) throws Exception {
Map<String, Object> update = new HashMap<String, Object>();
update.put("tags", Arrays.asList(tagLocation));
this.mockMvc.perform(
patch(noteLocation).contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(update)))
.andExpect(status().isNoContent())
.andDo(document("tag-existing-note"));
}
void getTaggedExistingNote(String tagLocation) throws Exception {
this.mockMvc.perform(get(tagLocation))
.andExpect(status().isOk())
.andDo(document("get-tagged-existing-note"));
}
void getTagsForExistingNote(String taggedNoteLocation) throws Exception {
String tagsLocation = getLink(this.mockMvc.perform(get(taggedNoteLocation))
.andReturn(), "note-tags");
this.mockMvc.perform(get(tagsLocation))
.andExpect(status().isOk())
.andExpect(jsonPath("_embedded.tags", hasSize(1)))
.andDo(document("get-tags-for-existing-note"));
}
private String getLink(MvcResult result, String rel)
throws UnsupportedEncodingException {
return JsonPath.parse(result.getResponse().getContentAsString()).read(
"_links." + rel + ".href");
}
}