diff --git a/restful-notes-spring-hateoas/.gitignore b/restful-notes-spring-hateoas/.gitignore new file mode 100644 index 0000000..5e3a83a --- /dev/null +++ b/restful-notes-spring-hateoas/.gitignore @@ -0,0 +1,7 @@ +.classpath +.gradle/ +.project +.settings/ +bin/ +build/ +out/ diff --git a/restful-notes-spring-hateoas/README.md b/restful-notes-spring-hateoas/README.md new file mode 100644 index 0000000..3a8b985 --- /dev/null +++ b/restful-notes-spring-hateoas/README.md @@ -0,0 +1,17 @@ +# Restful Notes Spring Hateoas Sample +A RESTful web service for creating and storing notes. +It uses hypermedia to describe the relationships between resources and to allow navigation between them. +Demonstrates using Spring REST Docs with MockMvc and Spring HATEOAS. + + + +## Building and Running the Sample +You will need Java 17 to build and run the sample. +It is build using Gradle: + +``` +./gradlew build +``` + +As part of the build, files named `build/docs/asciidoc/api-guide.html` and `build/docs/asciidoc/getting-started-guide.html` are created. +They are produced using Asciidoctor and include snippets generated by the sample's tests using Spring REST Docs. diff --git a/restful-notes-spring-hateoas/build.gradle b/restful-notes-spring-hateoas/build.gradle new file mode 100644 index 0000000..1f44075 --- /dev/null +++ b/restful-notes-spring-hateoas/build.gradle @@ -0,0 +1,57 @@ +plugins { + id "java" + id "org.asciidoctor.jvm.convert" version "3.3.2" + id "org.springframework.boot" version "3.0.0-M4" +} + +apply plugin: 'io.spring.dependency-management' + +repositories { + maven { url 'https://repo.spring.io/milestone' } + mavenCentral() +} + +group = 'com.example' + +sourceCompatibility = 17 +targetCompatibility = 17 + +ext { + snippetsDir = file('build/generated-snippets') +} + +configurations { + asciidoctorExtensions +} + +dependencies { + asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' + + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-hateoas' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + runtimeOnly 'com.h2database:h2' + runtimeOnly 'org.atteo:evo-inflector:1.3' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' +} + +test { + useJUnitPlatform() + outputs.dir snippetsDir +} + +asciidoctor { + configurations "asciidoctorExtensions" + inputs.dir snippetsDir + dependsOn test +} + +bootJar { + dependsOn asciidoctor + from ("${asciidoctor.outputDir}/html5") { + into 'static/docs' + } +} diff --git a/restful-notes-spring-hateoas/gradle/wrapper/.!33814!gradle-wrapper.jar b/restful-notes-spring-hateoas/gradle/wrapper/.!33814!gradle-wrapper.jar new file mode 100644 index 0000000..000fc61 Binary files /dev/null and b/restful-notes-spring-hateoas/gradle/wrapper/.!33814!gradle-wrapper.jar differ diff --git a/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.jar b/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..7454180 Binary files /dev/null and b/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.jar differ diff --git a/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.properties b/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..8049c68 --- /dev/null +++ b/restful-notes-spring-hateoas/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/restful-notes-spring-hateoas/gradlew b/restful-notes-spring-hateoas/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/restful-notes-spring-hateoas/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/restful-notes-spring-hateoas/gradlew.bat b/restful-notes-spring-hateoas/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/restful-notes-spring-hateoas/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/restful-notes-spring-hateoas/settings.gradle b/restful-notes-spring-hateoas/settings.gradle new file mode 100644 index 0000000..b1e9ad5 --- /dev/null +++ b/restful-notes-spring-hateoas/settings.gradle @@ -0,0 +1,6 @@ +pluginManagement { + repositories { + gradlePluginPortal() + maven { url "https://repo.spring.io/milestone" } + } +} \ No newline at end of file diff --git a/restful-notes-spring-hateoas/src/docs/asciidoc/api-guide.adoc b/restful-notes-spring-hateoas/src/docs/asciidoc/api-guide.adoc new file mode 100644 index 0000000..9074589 --- /dev/null +++ b/restful-notes-spring-hateoas/src/docs/asciidoc/api-guide.adoc @@ -0,0 +1,229 @@ += RESTful Notes API Guide +Andy Wilkinson; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: +:operation-curl-request-title: Example request +:operation-http-response-title: Example response + +[[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_headers]] +== Headers + +Every response has the following header(s): + +include::{snippets}/headers-example/response-headers.adoc[] + +[[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 structure: + +include::{snippets}/error-example/response-fields.adoc[] + +For example, a request that attempts to apply a non-existent tag to a note will produce a +`400 Bad Request` response: + +include::{snippets}/error-example/http-response.adoc[] + +[[overview_hypermedia]] +== Hypermedia + +RESTful Notes uses hypermedia and resources include links to other resources in their +responses. Responses are in https://github.com/mikekelly/hal_specification[Hypertext +Application Language (HAL)] format. Links can be found beneath the `_links` key. Users of +the API should not create 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 + +operation::index-example[snippets='response-fields,http-response,links'] + + + +[[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. + +operation::notes-list-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_notes_create]] +=== Creating a note + +A `POST` request is used to create a note. + +operation::notes-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[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. + +operation::tags-list-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_tags_create]] +=== Creating a tag + +A `POST` request is used to create a note + +operation::tags-create-example[snippets='request-fields,curl-request,http-response'] + + + +[[resources_note]] +== Note + +The Note resource is used to retrieve, update, and delete individual notes + + + +[[resources_note_links]] +=== Links + +include::{snippets}/note-get-example/links.adoc[] + + + +[[resources_note_retrieve]] +=== Retrieve a note + +A `GET` request will retrieve the details of a note + +operation::note-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_note_update]] +=== Update a note + +A `PATCH` request is used to update a note + +==== Request structure + +include::{snippets}/note-update-example/request-fields.adoc[] + +To leave an attribute of a note unchanged, any of the above may be omitted from the request. + +operation::note-update-example[snippets='curl-request,http-response'] + + + +[[resources_tag]] +== Tag + +The Tag resource is used to retrieve, update, and delete individual tags + + + +[[resources_tag_links]] +=== Links + +include::{snippets}/tag-get-example/links.adoc[] + + + +[[resources_tag_retrieve]] +=== Retrieve a tag + +A `GET` request will retrieve the details of a tag + +operation::tag-get-example[snippets='response-fields,curl-request,http-response'] + + + +[[resources_tag_update]] +=== Update a tag + +A `PATCH` request is used to update a tag + +operation::tag-update-example[snippets='request-fields,curl-request,http-response'] diff --git a/restful-notes-spring-hateoas/src/docs/asciidoc/getting-started-guide.adoc b/restful-notes-spring-hateoas/src/docs/asciidoc/getting-started-guide.adoc new file mode 100644 index 0000000..7e8346a --- /dev/null +++ b/restful-notes-spring-hateoas/src/docs/asciidoc/getting-started-guide.adoc @@ -0,0 +1,173 @@ += RESTful Notes Getting Started Guide +Andy Wilkinson; +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toclevels: 4 +:sectlinks: + +[[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_running_the_service]] +== Running the service +RESTful Notes is written using https://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. + +The first step is to clone the Git repository: + +[source,bash] +---- +$ git clone https://github.com/spring-projects/spring-restdocs +---- + +Once the clone is complete, you're ready to get the service up and running: + +[source,bash] +---- +$ cd samples/rest-notes-spring-hateoas +$ ./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::{snippets}/index/1/curl-request.adoc[] + +This request should yield the following response: + +include::{snippets}/index/1/http-response.adoc[] + +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::{snippets}/index/1/http-response.adoc[] + +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::{snippets}/creating-a-note/1/curl-request.adoc[] + +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::{snippets}/creating-a-note/1/http-response.adoc[] + +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::{snippets}/creating-a-note/2/curl-request.adoc[] + +This request will produce a response with the note's details in its body: + +include::{snippets}/creating-a-note/2/http-response.adoc[] + +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::{snippets}/index/1/http-response.adoc[] + +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::{snippets}/creating-a-note/3/curl-request.adoc[] + +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::{snippets}/creating-a-note/3/http-response.adoc[] + +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::{snippets}/creating-a-note/4/curl-request.adoc[] + +This request will produce a response with the tag's details in its body: + +include::{snippets}/creating-a-note/4/http-response.adoc[] + + + +[[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::{snippets}/creating-a-note/5/curl-request.adoc[] + +Once again, the response's `Location` header tells use the URI of the newly created note: + +include::{snippets}/creating-a-note/5/http-response.adoc[] + +As before, a `GET` request executed against this URI will retrieve the note's details: + +include::{snippets}/creating-a-note/6/curl-request.adoc[] +include::{snippets}/creating-a-note/6/http-response.adoc[] + +To see the note's tags, execute a `GET` request against the URI of the note's +`note-tags` link: + +include::{snippets}/creating-a-note/7/curl-request.adoc[] + +The response shows that, as expected, the note has a single tag: + +include::{snippets}/creating-a-note/7/http-response.adoc[] + + + +[[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 use the +URI of the untagged note that we created earlier: + +include::{snippets}/creating-a-note/8/curl-request.adoc[] + +This request should produce a `204 No Content` response: + +include::{snippets}/creating-a-note/8/http-response.adoc[] + +When we first created this note, we noted the `note-tags` link included in its details: + +include::{snippets}/creating-a-note/2/http-response.adoc[] + +We can use that link now and execute a `GET` request to see that the note now has a +single tag: + +include::{snippets}/creating-a-note/9/curl-request.adoc[] +include::{snippets}/creating-a-note/9/http-response.adoc[] diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/ExceptionSupressingErrorAttributes.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/ExceptionSupressingErrorAttributes.java new file mode 100644 index 0000000..3f06485 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/ExceptionSupressingErrorAttributes.java @@ -0,0 +1,41 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.web.error.ErrorAttributeOptions; +import org.springframework.boot.web.servlet.error.DefaultErrorAttributes; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.WebRequest; + +@Component +class ExceptionSupressingErrorAttributes extends DefaultErrorAttributes { + + @Override + public Map getErrorAttributes(WebRequest webRequest, + ErrorAttributeOptions options) { + Map errorAttributes = super.getErrorAttributes(webRequest, options); + errorAttributes.remove("exception"); + Object message = webRequest.getAttribute("javax.servlet.error.message", RequestAttributes.SCOPE_REQUEST); + if (message != null) { + errorAttributes.put("message", message); + } + return errorAttributes; + } +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/IndexController.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/IndexController.java new file mode 100644 index 0000000..aba9f11 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/IndexController.java @@ -0,0 +1,38 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server.mvc.WebMvcLinkBuilder.linkTo; + +import org.springframework.hateoas.RepresentationModel; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/") +class IndexController { + + @RequestMapping(method=RequestMethod.GET) + public RepresentationModel index() { + RepresentationModel index = new RepresentationModel<>(); + index.add(linkTo(NotesController.class).withRel("notes")); + index.add(linkTo(TagsController.class).withRel("tags")); + return index; + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/Note.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/Note.java new file mode 100644 index 0000000..41ce6c5 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/Note.java @@ -0,0 +1,73 @@ +/* + * Copyright 2014-2022 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 + * + * https://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 jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; + +@Entity +public class Note { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + private String title; + + private String body; + + @ManyToMany + private List tags; + + 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; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java new file mode 100644 index 0000000..c1792ba --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteInput.java @@ -0,0 +1,58 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +import jakarta.validation.constraints.NotBlank; + +class NoteInput { + + @NotBlank + private final String title; + + private final String body; + + private final List tagUris; + + @JsonCreator + NoteInput(@JsonProperty("title") String title, + @JsonProperty("body") String body, @JsonProperty("tags") List tagUris) { + this.title = title; + this.body = body; + this.tagUris = tagUris == null ? Collections.emptyList() : tagUris; + } + + String getTitle() { + return title; + } + + String getBody() { + return body; + } + + @JsonProperty("tags") + List getTagUris() { + return this.tagUris; + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java new file mode 100644 index 0000000..284b6dd --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotePatchInput.java @@ -0,0 +1,55 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +class NotePatchInput { + + @NullOrNotBlank + private final String title; + + private final String body; + + private final List tagUris; + + @JsonCreator + NotePatchInput(@JsonProperty("title") String title, + @JsonProperty("body") String body, @JsonProperty("tags") List tagUris) { + this.title = title; + this.body = body; + this.tagUris = tagUris == null ? Collections.emptyList() : tagUris; + } + + String getTitle() { + return title; + } + + String getBody() { + return body; + } + + @JsonProperty("tags") + List getTagUris() { + return this.tagUris; + } +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepository.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepository.java new file mode 100644 index 0000000..8e64ec6 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepository.java @@ -0,0 +1,30 @@ +/* + * Copyright 2014-2022 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 + * + * https://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 org.springframework.data.repository.CrudRepository; + +interface NoteRepository extends CrudRepository { + + Note findById(long id); + + List findByTagsIn(Collection tags); + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepresentationModelAssembler.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepresentationModelAssembler.java new file mode 100644 index 0000000..11f69f0 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NoteRepresentationModelAssembler.java @@ -0,0 +1,67 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.server.core.Relation; +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; +import org.springframework.stereotype.Component; + +import com.example.notes.NoteRepresentationModelAssembler.NoteModel; + +@Component +class NoteRepresentationModelAssembler extends RepresentationModelAssemblerSupport { + + NoteRepresentationModelAssembler() { + super(NotesController.class, NoteModel.class); + } + + @Override + public NoteModel toModel(Note entity) { + NoteModel noteModel = createModelWithId(entity.getId(), entity); + noteModel.add(linkTo(methodOn(NotesController.class).noteTags(entity.getId())).withRel("note-tags")); + return noteModel; + } + + @Override + protected NoteModel instantiateModel(Note entity) { + return new NoteModel(entity); + } + + @Relation(collectionRelation = "notes", itemRelation = "note") + static class NoteModel extends RepresentationModel { + + private final Note note; + + NoteModel(Note note) { + this.note = note; + } + + public String getTitle() { + return this.note.getTitle(); + } + + public String getBody() { + return this.note.getBody(); + } + + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java new file mode 100644 index 0000000..c2dec81 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NotesController.java @@ -0,0 +1,145 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server.mvc.WebMvcLinkBuilder.linkTo; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.hateoas.CollectionModel; +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.NoteRepresentationModelAssembler.NoteModel; +import com.example.notes.TagRepresentationModelAssembler.TagModel; + +@RestController +@RequestMapping("/notes") +class NotesController { + + private static final UriTemplate TAG_URI_TEMPLATE = new UriTemplate("/tags/{id}"); + + private final NoteRepository noteRepository; + + private final TagRepository tagRepository; + + private final NoteRepresentationModelAssembler noteAssembler; + + private final TagRepresentationModelAssembler tagAssembler; + + NotesController(NoteRepository noteRepository, TagRepository tagRepository, + NoteRepresentationModelAssembler noteAssembler, TagRepresentationModelAssembler tagAssembler) { + this.noteRepository = noteRepository; + this.tagRepository = tagRepository; + this.noteAssembler = noteAssembler; + this.tagAssembler = tagAssembler; + } + + @RequestMapping(method = RequestMethod.GET) + CollectionModel all() { + return noteAssembler.toCollectionModel(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.deleteById(id); + } + + @RequestMapping(value = "/{id}", method = RequestMethod.GET) + NoteModel note(@PathVariable("id") long id) { + return this.noteAssembler.toModel(findNoteById(id)); + } + + @RequestMapping(value = "/{id}/tags", method = RequestMethod.GET) + CollectionModel noteTags(@PathVariable("id") long id) { + return this.tagAssembler.toCollectionModel(findNoteById(id).getTags()); + } + + @RequestMapping(value = "/{id}", method = RequestMethod.PATCH) + @ResponseStatus(HttpStatus.NO_CONTENT) + void updateNote(@PathVariable("id") long id, @RequestBody NotePatchInput noteInput) { + Note note = findNoteById(id); + 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 Note findNoteById(long id) { + Note note = this.noteRepository.findById(id); + if (note == null) { + throw new ResourceDoesNotExistException(); + } + return note; + } + + private List getTags(List tagLocations) { + List tags = new ArrayList<>(tagLocations.size()); + for (URI tagLocation: tagLocations) { + Tag tag = this.tagRepository.findById(extractTagId(tagLocation)); + if (tag == null) { + throw new IllegalArgumentException("The tag '" + tagLocation + + "' does not exist"); + } + tags.add(tag); + } + return tags; + } + + 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"); + } + } +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java new file mode 100644 index 0000000..3dc16eb --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/NullOrNotBlank.java @@ -0,0 +1,46 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.hibernate.validator.constraints.CompositionType; +import org.hibernate.validator.constraints.ConstraintComposition; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Null; + +@ConstraintComposition(CompositionType.OR) +@Constraint(validatedBy = {}) +@Null +@NotBlank +@Target({ ElementType.FIELD, ElementType.PARAMETER }) +@Retention(RetentionPolicy.RUNTIME) +@interface NullOrNotBlank { + + String message() default "Must be null or not blank"; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/ResourceDoesNotExistException.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/ResourceDoesNotExistException.java new file mode 100644 index 0000000..410b909 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/ResourceDoesNotExistException.java @@ -0,0 +1,22 @@ +/* + * Copyright 2014-2022 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 + * + * https://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") +class ResourceDoesNotExistException extends RuntimeException { + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesControllerAdvice.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesControllerAdvice.java new file mode 100644 index 0000000..4349091 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesControllerAdvice.java @@ -0,0 +1,44 @@ +/* + * Copyright 2014-2022 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 + * + * https://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 org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@ControllerAdvice +class RestNotesControllerAdvice { + + @ExceptionHandler(IllegalArgumentException.class) + void handleIllegalArgumentException(IllegalArgumentException ex, + HttpServletResponse response) throws IOException { + response.sendError(HttpStatus.BAD_REQUEST.value(), ex.getMessage()); + } + + @ExceptionHandler(ResourceDoesNotExistException.class) + void handleResourceDoesNotExistException(ResourceDoesNotExistException ex, + HttpServletRequest request, HttpServletResponse response) throws IOException { + response.sendError(HttpStatus.NOT_FOUND.value(), + "The resource '" + request.getRequestURI() + "' does not exist"); + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesSpringHateoas.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesSpringHateoas.java new file mode 100644 index 0000000..506084f --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/RestNotesSpringHateoas.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.SpringBootApplication; + +@SpringBootApplication +class RestNotesSpringHateoas { + + public static void main(String[] args) { + SpringApplication.run(RestNotesSpringHateoas.class, args); + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/Tag.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/Tag.java new file mode 100644 index 0000000..66b18a5 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/Tag.java @@ -0,0 +1,63 @@ +/* + * Copyright 2014-2022 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 + * + * https://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 jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; + +@Entity +public class Tag { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private long id; + + private String name; + + @ManyToMany(mappedBy = "tags") + private List notes; + + 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; + } + + public List getNotes() { + return notes; + } + + public void setNotes(List notes) { + this.notes = notes; + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java new file mode 100644 index 0000000..fb7e90f --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagInput.java @@ -0,0 +1,38 @@ +/* + * Copyright 2014-2022 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 + * + * https://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; + +import jakarta.validation.constraints.NotBlank; + +class TagInput { + + @NotBlank + private final String name; + + @JsonCreator + TagInput(@NotBlank @JsonProperty("name") String name) { + this.name = name; + } + + String getName() { + return name; + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java new file mode 100644 index 0000000..a851d92 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagPatchInput.java @@ -0,0 +1,36 @@ +/* + * Copyright 2014-2022 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 + * + * https://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; + +class TagPatchInput { + + @NullOrNotBlank + private final String name; + + @JsonCreator + TagPatchInput(@NullOrNotBlank @JsonProperty("name") String name) { + this.name = name; + } + + String getName() { + return name; + } + +} \ No newline at end of file diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepository.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepository.java new file mode 100644 index 0000000..8e67906 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepository.java @@ -0,0 +1,25 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.data.repository.CrudRepository; + +interface TagRepository extends CrudRepository { + + Tag findById(long id); + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepresentationModelAssembler.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepresentationModelAssembler.java new file mode 100644 index 0000000..592f3c4 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagRepresentationModelAssembler.java @@ -0,0 +1,64 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.server.core.Relation; +import org.springframework.hateoas.server.mvc.RepresentationModelAssemblerSupport; +import org.springframework.stereotype.Component; + +import com.example.notes.TagRepresentationModelAssembler.TagModel; + +@Component +class TagRepresentationModelAssembler extends RepresentationModelAssemblerSupport { + + TagRepresentationModelAssembler() { + super(TagsController.class, TagModel.class); + } + + @Override + public TagModel toModel(Tag entity) { + TagModel model = new TagModel(entity); + model.add(linkTo(methodOn(TagsController.class).tag(entity.getId())).withSelfRel(), + linkTo(methodOn(TagsController.class).tagNotes(entity.getId())).withRel("tagged-notes")); + return model; + } + + @Override + protected TagModel instantiateModel(Tag entity) { + return new TagModel(entity); + } + + @Relation(collectionRelation = "tags", itemRelation = "tag") + static class TagModel extends RepresentationModel { + + private final Tag tag; + + TagModel(Tag tag) { + this.tag = tag; + } + + public String getName() { + return this.tag.getName(); + } + + } + +} diff --git a/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java new file mode 100644 index 0000000..12791bb --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/java/com/example/notes/TagsController.java @@ -0,0 +1,102 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.server.mvc.WebMvcLinkBuilder.linkTo; + +import org.springframework.hateoas.CollectionModel; +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.NoteRepresentationModelAssembler.NoteModel; +import com.example.notes.TagRepresentationModelAssembler.TagModel; + +@RestController +@RequestMapping("tags") +class TagsController { + + private final TagRepository repository; + + private final TagRepresentationModelAssembler tagAssembler; + + private final NoteRepresentationModelAssembler noteAssembler; + + TagsController(TagRepository repository, TagRepresentationModelAssembler tagAssembler, + NoteRepresentationModelAssembler noteAssembler) { + this.repository = repository; + this.tagAssembler = tagAssembler; + this.noteAssembler = noteAssembler; + } + + @RequestMapping(method = RequestMethod.GET) + CollectionModel all() { + return this.tagAssembler.toCollectionModel(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.deleteById(id); + } + + @RequestMapping(value = "/{id}", method = RequestMethod.GET) + TagModel tag(@PathVariable("id") long id) { + return this.tagAssembler.toModel(findTagById(id)); + } + + @RequestMapping(value = "/{id}/notes", method = RequestMethod.GET) + CollectionModel tagNotes(@PathVariable("id") long id) { + return this.noteAssembler.toCollectionModel(findTagById(id).getNotes()); + } + + private Tag findTagById(long id) { + Tag tag = this.repository.findById(id); + if (tag == null) { + throw new ResourceDoesNotExistException(); + } + return tag; + } + + @RequestMapping(value = "/{id}", method = RequestMethod.PATCH) + @ResponseStatus(HttpStatus.NO_CONTENT) + void updateTag(@PathVariable("id") long id, @RequestBody TagPatchInput tagInput) { + Tag tag = findTagById(id); + if (tagInput.getName() != null) { + tag.setName(tagInput.getName()); + } + this.repository.save(tag); + } +} diff --git a/restful-notes-spring-hateoas/src/main/resources/application.properties b/restful-notes-spring-hateoas/src/main/resources/application.properties new file mode 100644 index 0000000..c94d3e7 --- /dev/null +++ b/restful-notes-spring-hateoas/src/main/resources/application.properties @@ -0,0 +1 @@ +server.error.include-message=always \ No newline at end of file diff --git a/restful-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java b/restful-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java new file mode 100644 index 0000000..022ca5d --- /dev/null +++ b/restful-notes-spring-hateoas/src/test/java/com/example/notes/ApiDocumentation.java @@ -0,0 +1,402 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.headers.HeaderDocumentation.headerWithName; +import static org.springframework.restdocs.headers.HeaderDocumentation.responseHeaders; +import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.linkWithRel; +import static org.springframework.restdocs.hypermedia.HypermediaDocumentation.links; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.subsectionWithPath; +import static org.springframework.restdocs.snippet.Attributes.key; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.hateoas.MediaTypes; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.restdocs.constraints.ConstraintDescriptions; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.util.StringUtils; +import org.springframework.web.context.WebApplicationContext; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.RequestDispatcher; + +@SpringBootTest +@ExtendWith(RestDocumentationExtension.class) +public class ApiDocumentation { + + + private RestDocumentationResultHandler documentationHandler; + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + this.documentationHandler = document("{method-name}", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint())); + + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(this.documentationHandler) + .build(); + } + + @Test + void headersExample() throws Exception { + this.mockMvc + .perform(get("/")) + .andExpect(status().isOk()) + .andDo(this.documentationHandler.document( + responseHeaders( + headerWithName("Content-Type").description("The Content-Type of the payload, e.g. `application/hal+json`")))); + } + + @Test + 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")) + .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(this.documentationHandler.document( + responseFields( + fieldWithPath("error").description("The HTTP error that occurred, e.g. `Bad Request`"), + fieldWithPath("message").description("A description of the cause of the error"), + fieldWithPath("path").description("The path to which the request was made"), + fieldWithPath("status").description("The HTTP status code, e.g. `400`"), + fieldWithPath("timestamp").description("The time, in milliseconds, at which the error occurred")))); + } + + @Test + void indexExample() throws Exception { + this.mockMvc.perform(get("/")) + .andExpect(status().isOk()) + .andDo(this.documentationHandler.document( + links( + linkWithRel("notes").description("The <>"), + linkWithRel("tags").description("The <>")), + responseFields( + subsectionWithPath("_links").description("<> to other resources")))); + } + + @Test + void notesListExample() throws Exception { + this.noteRepository.deleteAll(); + + createNote("REST maturity model", "https://martinfowler.com/articles/richardsonMaturityModel.html"); + createNote("Hypertext Application Language (HAL)", "https://github.com/mikekelly/hal_specification"); + createNote("Application-Level Profile Semantics (ALPS)", "https://github.com/alps-io/spec"); + + this.mockMvc + .perform(get("/notes")) + .andExpect(status().isOk()) + .andDo(this.documentationHandler.document( + responseFields( + subsectionWithPath("_embedded.notes").description("An array of <>")))); + } + + @Test + 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", "https://martinfowler.com/articles/richardsonMaturityModel.html"); + note.put("tags", Arrays.asList(tagLocation)); + + ConstrainedFields fields = new ConstrainedFields(NoteInput.class); + + this.mockMvc + .perform(post("/notes") + .contentType(MediaTypes.HAL_JSON) + .content(this.objectMapper.writeValueAsString(note))) + .andExpect( + status().isCreated()) + .andDo(this.documentationHandler.document( + requestFields( + fields.withPath("title").description("The title of the note"), + fields.withPath("body").description("The body of the note"), + fields.withPath("tags").description("An array of tag resource URIs")))); + } + + @Test + 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", "https://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(this.documentationHandler.document( + links( + linkWithRel("self").description("This <>"), + linkWithRel("note-tags").description("This note's tags")), + responseFields( + fieldWithPath("title").description("The title of the note"), + fieldWithPath("body").description("The body of the note"), + subsectionWithPath("_links").description("<> to other resources")))); + + } + + @Test + 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(this.documentationHandler.document( + responseFields( + subsectionWithPath("_embedded.tags").description("An array of <>")))); + } + + @Test + void tagsCreateExample() throws Exception { + Map tag = new HashMap(); + tag.put("name", "REST"); + + ConstrainedFields fields = new ConstrainedFields(TagInput.class); + + this.mockMvc + .perform(post("/tags") + .contentType(MediaTypes.HAL_JSON) + .content(this.objectMapper.writeValueAsString(tag))) + .andExpect(status().isCreated()) + .andDo(this.documentationHandler.document( + requestFields( + fields.withPath("name").description("The name of the tag")))); + } + + @Test + void noteUpdateExample() throws Exception { + Map note = new HashMap(); + note.put("title", "REST maturity model"); + note.put("body", "https://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)); + + ConstrainedFields fields = new ConstrainedFields(NotePatchInput.class); + + this.mockMvc + .perform(patch(noteLocation) + .contentType(MediaTypes.HAL_JSON) + .content(this.objectMapper.writeValueAsString(noteUpdate))) + .andExpect(status().isNoContent()) + .andDo(this.documentationHandler.document( + requestFields( + fields.withPath("title") + .description("The title of the note") + .type(JsonFieldType.STRING) + .optional(), + fields.withPath("body") + .description("The body of the note") + .type(JsonFieldType.STRING) + .optional(), + fields.withPath("tags") + .description("An array of tag resource URIs")))); + } + + @Test + 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"); + + this.mockMvc + .perform(get(tagLocation)) + .andExpect(status().isOk()) + .andExpect(jsonPath("name", is(tag.get("name")))) + .andDo(this.documentationHandler.document( + links( + linkWithRel("self").description("This <>"), + linkWithRel("tagged-notes").description("The notes that have this tag")), + responseFields( + fieldWithPath("name").description("The name of the tag"), + subsectionWithPath("_links").description("<> to other resources")))); + } + + @Test + 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"); + + ConstrainedFields fields = new ConstrainedFields(TagPatchInput.class); + + this.mockMvc + .perform(patch(tagLocation) + .contentType(MediaTypes.HAL_JSON) + .content(this.objectMapper.writeValueAsString(tagUpdate))) + .andExpect(status().isNoContent()) + .andDo(this.documentationHandler.document( + requestFields( + fields.withPath("name").description("The name of the tag")))); + } + + 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); + } + + private static class ConstrainedFields { + + private final ConstraintDescriptions constraintDescriptions; + + ConstrainedFields(Class input) { + this.constraintDescriptions = new ConstraintDescriptions(input); + } + + private FieldDescriptor withPath(String path) { + return fieldWithPath(path).attributes(key("constraints").value(StringUtils + .collectionToDelimitedString(this.constraintDescriptions + .descriptionsForProperty(path), ". "))); + } + } + +} diff --git a/restful-notes-spring-hateoas/src/test/java/com/example/notes/GettingStartedDocumentation.java b/restful-notes-spring-hateoas/src/test/java/com/example/notes/GettingStartedDocumentation.java new file mode 100644 index 0000000..5203c2b --- /dev/null +++ b/restful-notes-spring-hateoas/src/test/java/com/example/notes/GettingStartedDocumentation.java @@ -0,0 +1,184 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.patch; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +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.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.hateoas.MediaTypes; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +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; + +@SpringBootTest +@ExtendWith(RestDocumentationExtension.class) +public class GettingStartedDocumentation { + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + void setUp(RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(restDocumentation)) + .alwaysDo(document("{method-name}/{step}/", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .build(); + } + + @Test + 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()))); + } + + @Test + void creatingANote() throws JsonProcessingException, Exception { + String noteLocation = createNote(); + MvcResult note = getNote(noteLocation); + + String tagLocation = createTag(); + getTag(tagLocation); + + String taggedNoteLocation = createTaggedNote(tagLocation); + MvcResult taggedNote = getNote(taggedNoteLocation); + getTags(getLink(taggedNote, "note-tags")); + + tagExistingNote(noteLocation, tagLocation); + getTags(getLink(note, "note-tags")); + } + + private String createNote() throws Exception { + Map note = new HashMap(); + 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())) + .andReturn().getResponse().getHeader("Location"); + return noteLocation; + } + + private MvcResult getNote(String noteLocation) throws Exception { + return 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()))) + .andReturn(); + } + + private String createTag() throws Exception, JsonProcessingException { + Map tag = new HashMap(); + 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())) + .andReturn().getResponse().getHeader("Location"); + return tagLocation; + } + + private 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()))); + } + + private String createTaggedNote(String tag) throws Exception { + Map note = new HashMap(); + 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())) + .andReturn().getResponse().getHeader("Location"); + return noteLocation; + } + + private void getTags(String noteTagsLocation) throws Exception { + this.mockMvc.perform(get(noteTagsLocation)) + .andExpect(status().isOk()) + .andExpect(jsonPath("_embedded.tags", hasSize(1))); + } + + private void tagExistingNote(String noteLocation, String tagLocation) throws Exception { + Map update = new HashMap(); + update.put("tags", Arrays.asList(tagLocation)); + + this.mockMvc.perform( + patch(noteLocation).contentType(MediaTypes.HAL_JSON).content( + objectMapper.writeValueAsString(update))) + .andExpect(status().isNoContent()); + } + + private String getLink(MvcResult result, String rel) + throws UnsupportedEncodingException { + return JsonPath.parse(result.getResponse().getContentAsString()).read( + "_links." + rel + ".href"); + } + +} diff --git a/restful-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java b/restful-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java new file mode 100644 index 0000000..0868f51 --- /dev/null +++ b/restful-notes-spring-hateoas/src/test/java/com/example/notes/NullOrNotBlankTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2014-2022 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 + * + * https://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.assertj.core.api.Assertions.assertThat; + +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validation; +import jakarta.validation.Validator; + +class NullOrNotBlankTests { + + private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator(); + + @Test + void nullValue() { + Set> violations = validator.validate(new Constrained(null)); + assertThat(violations).isEmpty(); + } + + @Test + void zeroLengthValue() { + Set> violations = validator.validate(new Constrained("")); + assertThat(violations).hasSize(2); + } + + @Test + void blankValue() { + Set> violations = validator.validate(new Constrained(" ")); + assertThat(violations).hasSize(2); + } + + @Test + void nonBlankValue() { + Set> violations = validator.validate(new Constrained("test")); + assertThat(violations).isEmpty(); + } + + static class Constrained { + + @NullOrNotBlank + private final String value; + + public Constrained(String value) { + this.value = value; + } + + } + +} diff --git a/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties b/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties new file mode 100644 index 0000000..b37e69b --- /dev/null +++ b/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/constraints/ConstraintDescriptions.properties @@ -0,0 +1 @@ +com.example.notes.NullOrNotBlank.description=Must be null or not blank diff --git a/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet b/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet new file mode 100644 index 0000000..cd1e825 --- /dev/null +++ b/restful-notes-spring-hateoas/src/test/resources/org/springframework/restdocs/templates/request-fields.snippet @@ -0,0 +1,11 @@ +|=== +|Path|Type|Description|Constraints + +{{#fields}} +|{{path}} +|{{type}} +|{{description}} +|{{constraints}} + +{{/fields}} +|=== \ No newline at end of file