diff --git a/restful-notes-spring-data-rest/.gitignore b/restful-notes-spring-data-rest/.gitignore new file mode 100644 index 0000000..d11829c --- /dev/null +++ b/restful-notes-spring-data-rest/.gitignore @@ -0,0 +1,4 @@ +.classpath +.project +.settings/ +target/ diff --git a/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.jar b/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..bf82ff0 Binary files /dev/null and b/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.jar differ diff --git a/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.properties b/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..3d64498 --- /dev/null +++ b/restful-notes-spring-data-rest/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.8.3/apache-maven-3.8.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.1/maven-wrapper-3.1.1.jar diff --git a/restful-notes-spring-data-rest/README.md b/restful-notes-spring-data-rest/README.md new file mode 100644 index 0000000..8b705df --- /dev/null +++ b/restful-notes-spring-data-rest/README.md @@ -0,0 +1,17 @@ +# Restful Notes Spring Data REST 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 Data REST. + + + +## Building and Running the Sample +You will need Java 17 to build and run the sample. +It is built using Maven: + +``` +./mvnw verify +``` + +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-data-rest/mvnw b/restful-notes-spring-data-rest/mvnw new file mode 100755 index 0000000..63fcc8f --- /dev/null +++ b/restful-notes-spring-data-rest/mvnw @@ -0,0 +1,234 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # + # Look for the Apple JDKs first to preserve the existing behaviour, and then look + # for the new JDKs provided by Oracle. + # + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Frameworks/JavaVM.framework/Versions/CurrentJDK/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L /System/Library/Java/JavaVirtualMachines/CurrentJDK ] ; then + # + # Apple JDKs + # + export JAVA_HOME=/System/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -L "/Library/Java/JavaVirtualMachines/CurrentJDK" ] ; then + # + # Oracle JDKs + # + export JAVA_HOME=/Library/Java/JavaVirtualMachines/CurrentJDK/Contents/Home + fi + + if [ -z "$JAVA_HOME" ] && [ -x "/usr/libexec/java_home" ]; then + # + # Apple JDKs + # + export JAVA_HOME=`/usr/libexec/java_home` + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + local basedir=$(pwd) + local wdir=$(pwd) + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + wdir=$(cd "$wdir/.."; pwd) + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-$(find_maven_basedir)} +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CMD_LINE_ARGS + diff --git a/restful-notes-spring-data-rest/mvnw.cmd b/restful-notes-spring-data-rest/mvnw.cmd new file mode 100644 index 0000000..66e928b --- /dev/null +++ b/restful-notes-spring-data-rest/mvnw.cmd @@ -0,0 +1,145 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. 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, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +set MAVEN_CMD_LINE_ARGS=%MAVEN_CONFIG% %* + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR=""%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CMD_LINE_ARGS% +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/restful-notes-spring-data-rest/pom.xml b/restful-notes-spring-data-rest/pom.xml new file mode 100644 index 0000000..c9c84a6 --- /dev/null +++ b/restful-notes-spring-data-rest/pom.xml @@ -0,0 +1,135 @@ + + + 4.0.0 + + com.example + restful-notes-spring-data-rest + 0.0.1-SNAPSHOT + jar + + + org.springframework.boot + spring-boot-starter-parent + 3.0.0-M4 + + + + + UTF-8 + 17 + + + + + org.springframework.boot + spring-boot-starter-data-rest + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + runtime + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.restdocs + spring-restdocs-mockmvc + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*Documentation.java + + + + + org.asciidoctor + asciidoctor-maven-plugin + 2.2.2 + + + generate-docs + prepare-package + + process-asciidoc + + + html + book + + + + + + org.springframework.restdocs + spring-restdocs-asciidoctor + ${spring-restdocs.version} + + + + + maven-resources-plugin + + + copy-resources + prepare-package + + copy-resources + + + ${project.build.outputDirectory}/static/docs + + + ${project.build.directory}/generated-docs + + + + + + + + + + + + spring-milestones + Spring milestones + https://repo.spring.io/milestone + + false + + + + + + spring-milestones + Spring milestones + https://repo.spring.io/milestone + + false + + + + + diff --git a/restful-notes-spring-data-rest/src/main/asciidoc/api-guide.adoc b/restful-notes-spring-data-rest/src/main/asciidoc/api-guide.adoc new file mode 100644 index 0000000..8cb8178 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/asciidoc/api-guide.adoc @@ -0,0 +1,228 @@ += 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_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,links'] + + + +[[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,links'] + + + +[[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. + +==== Example request + +include::{snippets}/note-update-example/curl-request.adoc[] + +==== Example response + +include::{snippets}/note-update-example/http-response.adoc[] + + + +[[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-data-rest/src/main/asciidoc/getting-started-guide.adoc b/restful-notes-spring-data-rest/src/main/asciidoc/getting-started-guide.adoc new file mode 100644 index 0000000..c1af376 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/asciidoc/getting-started-guide.adoc @@ -0,0 +1,175 @@ += 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-samples +---- + +Once the clone is complete, you're ready to get the service up and running: + +[source,bash] +---- +$ cd restful-notes-spring-data-rest +$ ./mvnw clean package +$ java -jar target/*.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 in the +https://github.com/mikekelly/hal_specification[Hypertext Application Language (HAL)] +format: + +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 `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. However, 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 us 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 verify that the tag has been associated with the note, we can perform a `GET` request +against the URI from the `tags` link: + +include::{snippets}/creating-a-note/7/curl-request.adoc[] + +The response embeds information about the tag that we've just associated with the note: + +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 used 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 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-data-rest/src/main/java/com/example/notes/Note.java b/restful-notes-spring-data-rest/src/main/java/com/example/notes/Note.java new file mode 100644 index 0000000..6f83259 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/java/com/example/notes/Note.java @@ -0,0 +1,75 @@ +/* + * 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; + +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 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; + } + + public List getTags() { + return tags; + } + + public void setTags(List tags) { + this.tags = tags; + } +} diff --git a/restful-notes-spring-data-rest/src/main/java/com/example/notes/NoteRepository.java b/restful-notes-spring-data-rest/src/main/java/com/example/notes/NoteRepository.java new file mode 100644 index 0000000..c99dacc --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/java/com/example/notes/NoteRepository.java @@ -0,0 +1,23 @@ +/* + * 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; + +public interface NoteRepository extends CrudRepository { + +} diff --git a/restful-notes-spring-data-rest/src/main/java/com/example/notes/RestNotesSpringDataRest.java b/restful-notes-spring-data-rest/src/main/java/com/example/notes/RestNotesSpringDataRest.java new file mode 100644 index 0000000..f9670e5 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/java/com/example/notes/RestNotesSpringDataRest.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 +public class RestNotesSpringDataRest { + + public static void main(String[] args) { + SpringApplication.run(RestNotesSpringDataRest.class, args); + } + +} diff --git a/restful-notes-spring-data-rest/src/main/java/com/example/notes/Tag.java b/restful-notes-spring-data-rest/src/main/java/com/example/notes/Tag.java new file mode 100644 index 0000000..317851f --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/java/com/example/notes/Tag.java @@ -0,0 +1,65 @@ +/* + * 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; + +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 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; + } + + public List getNotes() { + return notes; + } + + public void setNotes(List notes) { + this.notes = notes; + } +} diff --git a/restful-notes-spring-data-rest/src/main/java/com/example/notes/TagRepository.java b/restful-notes-spring-data-rest/src/main/java/com/example/notes/TagRepository.java new file mode 100644 index 0000000..9fa5430 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/java/com/example/notes/TagRepository.java @@ -0,0 +1,23 @@ +/* + * 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; + +public interface TagRepository extends CrudRepository { + +} diff --git a/restful-notes-spring-data-rest/src/main/resources/application.properties b/restful-notes-spring-data-rest/src/main/resources/application.properties new file mode 100644 index 0000000..8e06a82 --- /dev/null +++ b/restful-notes-spring-data-rest/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.jackson.serialization.indent_output: true \ No newline at end of file diff --git a/restful-notes-spring-data-rest/src/test/java/com/example/notes/ApiDocumentation.java b/restful-notes-spring-data-rest/src/test/java/com/example/notes/ApiDocumentation.java new file mode 100644 index 0000000..baa879d --- /dev/null +++ b/restful-notes-spring-data-rest/src/test/java/com/example/notes/ApiDocumentation.java @@ -0,0 +1,347 @@ +/* + * 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.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.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.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 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.payload.JsonFieldType; +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; + +import jakarta.servlet.RequestDispatcher; + +@SpringBootTest +@ExtendWith(RestDocumentationExtension.class) +class ApiDocumentation { + + @Autowired + private NoteRepository noteRepository; + + @Autowired + private TagRepository tagRepository; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + private MockMvc mockMvc; + + @BeforeEach + public void setUp(RestDocumentationContextProvider restDocumentation) { + this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context) + .apply(documentationConfiguration(restDocumentation)).build(); + } + + @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")) + .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", + responseFields( + fieldWithPath("error").description("The HTTP error that occurred, e.g. `Bad Request`"), + 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(document("index-example", + links( + linkWithRel("notes").description("The <>"), + linkWithRel("tags").description("The <>"), + linkWithRel("profile").description("The ALPS profile for the service")), + 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(document("notes-list-example", + links( + linkWithRel("self").description("Canonical link for this resource"), + linkWithRel("profile").description("The ALPS profile for this resource")), + responseFields( + subsectionWithPath("_embedded.notes").description("An array of <>"), + subsectionWithPath("_links").description("<> to other resources")))); + } + + @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)); + + this.mockMvc.perform( + post("/notes").contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(note))).andExpect( + status().isCreated()) + .andDo(document("notes-create-example", + requestFields( + fieldWithPath("title").description("The title of the note"), + fieldWithPath("body").description("The body of the note"), + fieldWithPath("tags").description("An array of tag resource URIs")))); + } + + @Test + 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.tags", is(notNullValue()))) + .andDo(print()) + .andDo(document("note-get-example", + links( + linkWithRel("self").description("Canonical link for this <>"), + linkWithRel("note").description("This <>"), + linkWithRel("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(document("tags-list-example", + links( + linkWithRel("self").description("Canonical link for this resource"), + linkWithRel("profile").description("The ALPS profile for this resource")), + responseFields( + subsectionWithPath("_embedded.tags").description("An array of <>"), + subsectionWithPath("_links").description("<> to other resources")))); + } + + @Test + void tagsCreateExample() throws Exception { + Map tag = new HashMap(); + 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", + requestFields( + fieldWithPath("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.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)); + + this.mockMvc.perform( + patch(noteLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(noteUpdate))) + .andExpect(status().isNoContent()) + .andDo(document("note-update-example", + requestFields( + fieldWithPath("title").description("The title of the note").type(JsonFieldType.STRING).optional(), + fieldWithPath("body").description("The body of the note").type(JsonFieldType.STRING).optional(), + fieldWithPath("tags").description("An array of tag resource URIs").optional()))); + } + + @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(document("tag-get-example", + links( + linkWithRel("self").description("Canonical link for this <>"), + linkWithRel("tag").description("This <>"), + linkWithRel("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"); + + this.mockMvc.perform( + patch(tagLocation).contentType(MediaTypes.HAL_JSON).content( + this.objectMapper.writeValueAsString(tagUpdate))) + .andExpect(status().isNoContent()) + .andDo(document("tag-update-example", + requestFields( + fieldWithPath("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); + } +} diff --git a/restful-notes-spring-data-rest/src/test/java/com/example/notes/GettingStartedDocumentation.java b/restful-notes-spring-data-rest/src/test/java/com/example/notes/GettingStartedDocumentation.java new file mode 100644 index 0000000..32a3d23 --- /dev/null +++ b/restful-notes-spring-data-rest/src/test/java/com/example/notes/GettingStartedDocumentation.java @@ -0,0 +1,179 @@ +/* + * 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.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) +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}/")) + .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, "tags")); + + tagExistingNote(noteLocation, tagLocation); + getTags(getLink(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.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.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"); + } + +}