Add RESTful Notes Spring Hateoas sample

This commit is contained in:
Andy Wilkinson
2022-07-26 12:30:01 +01:00
parent 0ed0d74c7b
commit 58cf479ca1
35 changed files with 2460 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
.classpath
.gradle/
.project
.settings/
bin/
build/
out/

View File

@@ -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.

View File

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

Binary file not shown.

View File

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

234
restful-notes-spring-hateoas/gradlew vendored Executable file
View File

@@ -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" "$@"

View File

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

View File

@@ -0,0 +1,6 @@
pluginManagement {
repositories {
gradlePluginPortal()
maven { url "https://repo.spring.io/milestone" }
}
}

View File

@@ -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']

View File

@@ -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[]

View File

@@ -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<String, Object> getErrorAttributes(WebRequest webRequest,
ErrorAttributeOptions options) {
Map<String, Object> 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;
}
}

View File

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

View File

@@ -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<Tag> 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<Tag> getTags() {
return tags;
}
public void setTags(List<Tag> tags) {
this.tags = tags;
}
}

View File

@@ -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<URI> tagUris;
@JsonCreator
NoteInput(@JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI>emptyList() : tagUris;
}
String getTitle() {
return title;
}
String getBody() {
return body;
}
@JsonProperty("tags")
List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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<URI> tagUris;
@JsonCreator
NotePatchInput(@JsonProperty("title") String title,
@JsonProperty("body") String body, @JsonProperty("tags") List<URI> tagUris) {
this.title = title;
this.body = body;
this.tagUris = tagUris == null ? Collections.<URI>emptyList() : tagUris;
}
String getTitle() {
return title;
}
String getBody() {
return body;
}
@JsonProperty("tags")
List<URI> getTagUris() {
return this.tagUris;
}
}

View File

@@ -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, Long> {
Note findById(long id);
List<Note> findByTagsIn(Collection<Tag> tags);
}

View File

@@ -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<Note, NoteModel> {
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<NoteModel> {
private final Note note;
NoteModel(Note note) {
this.note = note;
}
public String getTitle() {
return this.note.getTitle();
}
public String getBody() {
return this.note.getBody();
}
}
}

View File

@@ -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<NoteModel> 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<TagModel> 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<Tag> getTags(List<URI> tagLocations) {
List<Tag> 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");
}
}
}

View File

@@ -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<? extends Payload>[] payload() default {};
}

View File

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

View File

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

View File

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

View File

@@ -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<Note> 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<Note> getNotes() {
return notes;
}
public void setNotes(List<Note> notes) {
this.notes = notes;
}
}

View File

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

View File

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

View File

@@ -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, Long> {
Tag findById(long id);
}

View File

@@ -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<Tag, TagModel> {
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<TagModel> {
private final Tag tag;
TagModel(Tag tag) {
this.tag = tag;
}
public String getName() {
return this.tag.getName();
}
}
}

View File

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

View File

@@ -0,0 +1 @@
server.error.include-message=always

View File

@@ -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 <<resources_notes,Notes resource>>"),
linkWithRel("tags").description("The <<resources_tags,Tags resource>>")),
responseFields(
subsectionWithPath("_links").description("<<resources_index_access_links,Links>> 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 <<resources_note, Note resources>>"))));
}
@Test
void notesCreateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
String tagLocation = this.mockMvc
.perform(post("/tags")
.contentType(MediaTypes.HAL_JSON)
.content(this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andReturn().getResponse().getHeader("Location");
Map<String, Object> note = new HashMap<String, Object>();
note.put("title", "REST maturity model");
note.put("body", "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<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
String tagLocation = this.mockMvc
.perform(post("/tags")
.contentType(MediaTypes.HAL_JSON)
.content(this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andReturn().getResponse().getHeader("Location");
Map<String, Object> note = new HashMap<String, Object>();
note.put("title", "REST maturity model");
note.put("body", "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 <<resources_note,note>>"),
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("<<resources_note_links,Links>> 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 <<resources_tag,Tag resources>>"))));
}
@Test
void tagsCreateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
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<String, Object> note = new HashMap<String, Object>();
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<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
String tagLocation = this.mockMvc
.perform(post("/tags")
.contentType(MediaTypes.HAL_JSON)
.content(this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andReturn().getResponse().getHeader("Location");
Map<String, Object> noteUpdate = new HashMap<String, Object>();
noteUpdate.put("tags", Arrays.asList(tagLocation));
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<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
String tagLocation = this.mockMvc
.perform(post("/tags")
.contentType(MediaTypes.HAL_JSON)
.content(this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andReturn().getResponse().getHeader("Location");
this.mockMvc
.perform(get(tagLocation))
.andExpect(status().isOk())
.andExpect(jsonPath("name", is(tag.get("name"))))
.andDo(this.documentationHandler.document(
links(
linkWithRel("self").description("This <<resources_tag,tag>>"),
linkWithRel("tagged-notes").description("The notes that have this tag")),
responseFields(
fieldWithPath("name").description("The name of the tag"),
subsectionWithPath("_links").description("<<resources_tag_links,Links>> to other resources"))));
}
@Test
void tagUpdateExample() throws Exception {
Map<String, String> tag = new HashMap<String, String>();
tag.put("name", "REST");
String tagLocation = this.mockMvc
.perform(post("/tags")
.contentType(MediaTypes.HAL_JSON)
.content(this.objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andReturn().getResponse().getHeader("Location");
Map<String, Object> tagUpdate = new HashMap<String, Object>();
tagUpdate.put("name", "RESTful");
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), ". ")));
}
}
}

View File

@@ -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<String, String> note = new HashMap<String, String>();
note.put("title", "Note creation with cURL");
note.put("body", "An example of how to create a note using cURL");
String noteLocation = this.mockMvc
.perform(
post("/notes").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.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<String, String> tag = new HashMap<String, String>();
tag.put("name", "getting-started");
String tagLocation = this.mockMvc
.perform(
post("/tags").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(tag)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.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<String, Object> note = new HashMap<String, Object>();
note.put("title", "Tagged note creation with cURL");
note.put("body", "An example of how to create a tagged note using cURL");
note.put("tags", Arrays.asList(tag));
String noteLocation = this.mockMvc
.perform(
post("/notes").contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(note)))
.andExpect(status().isCreated())
.andExpect(header().string("Location", notNullValue()))
.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<String, Object> update = new HashMap<String, Object>();
update.put("tags", Arrays.asList(tagLocation));
this.mockMvc.perform(
patch(noteLocation).contentType(MediaTypes.HAL_JSON).content(
objectMapper.writeValueAsString(update)))
.andExpect(status().isNoContent());
}
private String getLink(MvcResult result, String rel)
throws UnsupportedEncodingException {
return JsonPath.parse(result.getResponse().getContentAsString()).read(
"_links." + rel + ".href");
}
}

View File

@@ -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<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(null));
assertThat(violations).isEmpty();
}
@Test
void zeroLengthValue() {
Set<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(""));
assertThat(violations).hasSize(2);
}
@Test
void blankValue() {
Set<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained(" "));
assertThat(violations).hasSize(2);
}
@Test
void nonBlankValue() {
Set<ConstraintViolation<Constrained>> violations = validator.validate(new Constrained("test"));
assertThat(violations).isEmpty();
}
static class Constrained {
@NullOrNotBlank
private final String value;
public Constrained(String value) {
this.value = value;
}
}
}

View File

@@ -0,0 +1 @@
com.example.notes.NullOrNotBlank.description=Must be null or not blank

View File

@@ -0,0 +1,11 @@
|===
|Path|Type|Description|Constraints
{{#fields}}
|{{path}}
|{{type}}
|{{description}}
|{{constraints}}
{{/fields}}
|===