diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..73f69e0 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..d937403 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..9dbd34c --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/simpsons-coding-challenge.iml b/.idea/simpsons-coding-challenge.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/simpsons-coding-challenge.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/complete/.gradle/5.6.3/fileChanges/last-build.bin b/complete/.gradle/5.6.3/fileChanges/last-build.bin new file mode 100644 index 0000000..f76dd23 Binary files /dev/null and b/complete/.gradle/5.6.3/fileChanges/last-build.bin differ diff --git a/complete/.gradle/5.6.3/fileHashes/fileHashes.lock b/complete/.gradle/5.6.3/fileHashes/fileHashes.lock new file mode 100644 index 0000000..a940126 Binary files /dev/null and b/complete/.gradle/5.6.3/fileHashes/fileHashes.lock differ diff --git a/complete/.gradle/5.6.3/gc.properties b/complete/.gradle/5.6.3/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/complete/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/complete/.gradle/buildOutputCleanup/buildOutputCleanup.lock new file mode 100644 index 0000000..15fabbb Binary files /dev/null and b/complete/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/complete/.gradle/buildOutputCleanup/cache.properties b/complete/.gradle/buildOutputCleanup/cache.properties new file mode 100644 index 0000000..f269ca2 --- /dev/null +++ b/complete/.gradle/buildOutputCleanup/cache.properties @@ -0,0 +1,2 @@ +#Tue Apr 28 19:41:33 BST 2020 +gradle.version=5.6.3 diff --git a/complete/.gradle/vcs-1/gc.properties b/complete/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/complete/.mvn/wrapper/maven-wrapper.jar b/complete/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..5fd4d50 Binary files /dev/null and b/complete/.mvn/wrapper/maven-wrapper.jar differ diff --git a/complete/.mvn/wrapper/maven-wrapper.properties b/complete/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c954cec --- /dev/null +++ b/complete/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.3.9/apache-maven-3.3.9-bin.zip diff --git a/complete/build.gradle b/complete/build.gradle new file mode 100644 index 0000000..31430c9 --- /dev/null +++ b/complete/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'org.springframework.boot' version '2.2.0.RELEASE' + id 'io.spring.dependency-management' version '1.0.8.RELEASE' + id 'java' +} + +group = 'com.example' +version = '0.0.1-SNAPSHOT' +sourceCompatibility = '1.8' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + testImplementation 'org.apache.httpcomponents:httpclient' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } +} + +test { + useJUnitPlatform() +} diff --git a/complete/data/characters.json b/complete/data/characters.json new file mode 100644 index 0000000..e91b229 --- /dev/null +++ b/complete/data/characters.json @@ -0,0 +1,66 @@ +{ + "data": [{ + "_id": "59edee68706374dfa957842f", + "firstName": "Homer", + "lastName": "Simpson", + "picture": "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + "age": 43 + }, + { + "_id": "59edee689509e51682ff8e02", + "firstName": "Marge", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + "age": 40 + }, + { + "_id": "59edee68eff3f80413c136f8", + "firstName": "Lisa", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/1/12/Lisa_Simpson-0.png/revision/latest?cb=20161027220133", + "age": 10 + }, + { + "_id": "59edee683406c7834ee7cdd8", + "firstName": "Bart", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/a/ab/BartSimpson.jpg/revision/latest?cb=20141101133153", + "age": 12 + }, + { + "_id": "59edee68b4b101bef064bf11", + "firstName": "Hugo", + "lastName": "Simpson", + "picture": "https://static.simpsonswiki.com/images/thumb/1/1a/Hugo_Simpson.png/200px-Hugo_Simpson.png", + "age": 12 + }, + { + "_id": "59edee68874eb2fa23678344", + "firstName": "Ned", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/84/Ned_Flanders.png/revision/latest?cb=20100513160156", + "age": 50 + }, + { + "_id": "59edee68efd448eefb265420", + "firstName": "Rod", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/e/e6/Rod_Flanders2.png/revision/latest?cb=20140817110904", + "age": 13 + }, + { + "_id": "59edee68ea392a3947485d41", + "firstName": "Todd", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/1/18/Todd_Flanders.png/revision/latest?cb=20131223200228", + "age": 12 + }, + { + "_id": "59edee682c7acf7bfac7e66b", + "firstName": "Maude", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/9/95/Maude_Flanders.png/revision/latest/scale-to-width-down/286?cb=20170923223722", + "age": 49 + } + ] +} diff --git a/complete/data/phrases.json b/complete/data/phrases.json new file mode 100644 index 0000000..56fca9f --- /dev/null +++ b/complete/data/phrases.json @@ -0,0 +1,88 @@ +{ + "data": [{ + "_id": "59edff64d9be8f7aa11e0c44", + "character": "59edee68706374dfa957842f", + "phrase": "Wait a minute. Bart’s teacher is named ‘Krabappel’? Oh, I’ve been calling her ‘Crandall.’ Why didn’t anyone tell me? Ohhh, I’ve been making an idiot out of myself!" + }, + { + "_id": "59edff6492d619b4a933a56b", + "character": "59edee68706374dfa957842f", + "phrase": "Now we play the waiting game…Ahh, the waiting game sucks. Let’s play Hungry Hungry Hippos!" + }, + { + "_id": "59edff6477ac8539e526682b", + "character": "59edee689509e51682ff8e02", + "phrase": "Go out on a Tuesday? Who am I, Charlie Sheen?" + }, + { + "_id": "59edff647cf388e225bc5f4e", + "character": "59edee689509e51682ff8e02", + "phrase": "I brought you a tuna sandwich. They say it's brain food. I guess because there's so much dolphin in it, and you know how smart they are." + }, + { + "_id": "59edff64ac4dccc4b0e35784", + "character": "59edee68eff3f80413c136f8", + "phrase": "I’d be mortified if someone ever made a lousy product with the Simpson name on it." + }, + { + "_id": "59edff6419f9d6df24d593fe", + "character": "59edee68eff3f80413c136f8", + "phrase": "You monster! You monster!" + }, + { + "_id": "59edff6403042a54f6038044", + "character": "59edee683406c7834ee7cdd8", + "phrase": "There’s only one thing to do at a moment like this: strut!" + }, + { + "_id": "59edff643d4737e47c71835c", + "character": "59edee683406c7834ee7cdd8", + "phrase": "Aren’t we forgetting the true meaning of Christmas: the birth of Santa." + }, + { + "_id": "59edff641b4c1f62aebe1e4d", + "character": "59edee68b4b101bef064bf11", + "phrase": "I made a Pigeon-rat." + }, + { + "_id": "59edff643fbeca90867aa34d", + "character": "59edee68b4b101bef064bf11", + "phrase": "Am I? Well, perhaps we're all a little crazy. I know I am. I went mad after they tore us apart, but I'll be sane… once I sew us back together." + }, + { + "_id": "59edff6494f9aef192ef4813", + "character": "59edee68874eb2fa23678344", + "phrase": "Diddly" + }, + { + "_id": "59edff64e6597a07e8e5dd33", + "character": "59edee68874eb2fa23678344", + "phrase": "I give you the jury of the damned. Benedict Arnold, Lizzie Borden, Richard Nixon..." + }, + { + "_id": "59edff6458b6a68631660120", + "character": "59edee68efd448eefb265420", + "phrase": "We just move one space at a time. It's less fun that way." + }, + { + "_id": "59edff646c740f1218a30a06", + "character": "59edee68efd448eefb265420", + "phrase": "Thank you, door! " + }, + { + "_id": "59edff644abadfc108cac2a6", + "character": "59edee68ea392a3947485d41", + "phrase": "Daddy says dice are wicked." + }, + { + "_id": "59edff64b5988eeae9953b59", + "character": "59edee68ea392a3947485d41", + "phrase": "Thank you, door! " + }, + { + "_id": "59edff64fd7790417d74b3a1", + "character": "59edee682c7acf7bfac7e66b", + "phrase": "Oh, I don't care for the speed, but I can't get enough of that safety gear - helmets, roll bars, caution flags..." + } + ] +} diff --git a/complete/gradle/wrapper/gradle-wrapper.jar b/complete/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..f6b961f Binary files /dev/null and b/complete/gradle/wrapper/gradle-wrapper.jar differ diff --git a/complete/gradle/wrapper/gradle-wrapper.properties b/complete/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f04d6a2 --- /dev/null +++ b/complete/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/complete/gradlew b/complete/gradlew new file mode 100644 index 0000000..83f2acf --- /dev/null +++ b/complete/gradlew @@ -0,0 +1,188 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# 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 UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +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 +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$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 "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# 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 + ;; + 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" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/complete/gradlew.bat b/complete/gradlew.bat new file mode 100644 index 0000000..9618d8d --- /dev/null +++ b/complete/gradlew.bat @@ -0,0 +1,100 @@ +@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 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 init + +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 init + +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 + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +: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 %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/complete/manifest.yml b/complete/manifest.yml new file mode 100644 index 0000000..f48c3b0 --- /dev/null +++ b/complete/manifest.yml @@ -0,0 +1,11 @@ +--- +applications: +- name: rest-service-guides + memory: 256M + instances: 1 + random-route: true + domain: cfapps.io + timeout: 180 + buildpack: java_buildpack +# For Maven target/gs-rest-service-cors-0.1.0.jar + path: build/libs/gs-rest-service-cors-0.1.0.jar diff --git a/complete/mvnw b/complete/mvnw new file mode 100644 index 0000000..bdc760d --- /dev/null +++ b/complete/mvnw @@ -0,0 +1,233 @@ +./mvnw spring-boot:run -Dserver.port=9000#!/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} "$@" diff --git a/complete/mvnw.cmd b/complete/mvnw.cmd new file mode 100644 index 0000000..4b98b78 --- /dev/null +++ b/complete/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=%* + +@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="".\.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% \ No newline at end of file diff --git a/complete/pom.xml b/complete/pom.xml new file mode 100644 index 0000000..68e30d2 --- /dev/null +++ b/complete/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.2.0.RELEASE + + + com.example + rest-service-cors + 0.0.1-SNAPSHOT + rest-service-cors + Demo project for Spring Boot + + + 1.8 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.apache.httpcomponents + httpclient + test + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.junit.vintage + junit-vintage-engine + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + diff --git a/complete/public/hello.js b/complete/public/hello.js new file mode 100644 index 0000000..b9e904b --- /dev/null +++ b/complete/public/hello.js @@ -0,0 +1,9 @@ +$(document).ready(function() { + $.ajax({ + url: "http://localhost:8080/greeting" + }).then(function(data, status, jqxhr) { + $('.greeting-id').append(data.id); + $('.greeting-content').append(data.content); + console.log(jqxhr); + }); +}); diff --git a/complete/public/index.html b/complete/public/index.html new file mode 100644 index 0000000..6f66dd7 --- /dev/null +++ b/complete/public/index.html @@ -0,0 +1,15 @@ + + + + Hello CORS + + + + + +
+

The ID is

+

The content is

+
+ + diff --git a/complete/settings.gradle b/complete/settings.gradle new file mode 100644 index 0000000..6afc092 --- /dev/null +++ b/complete/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'rest-service-cors' diff --git a/complete/src/main/java/com/example/restservicecors/Greeting.java b/complete/src/main/java/com/example/restservicecors/Greeting.java new file mode 100644 index 0000000..628685c --- /dev/null +++ b/complete/src/main/java/com/example/restservicecors/Greeting.java @@ -0,0 +1,25 @@ +package com.example.restservicecors; + +public class Greeting { + + private final long id; + private final String content; + + public Greeting() { + this.id = -1; + this.content = ""; + } + + public Greeting(long id, String content) { + this.id = id; + this.content = content; + } + + public long getId() { + return id; + } + + public String getContent() { + return content; + } +} diff --git a/complete/src/main/java/com/example/restservicecors/GreetingController.java b/complete/src/main/java/com/example/restservicecors/GreetingController.java new file mode 100644 index 0000000..f5018d2 --- /dev/null +++ b/complete/src/main/java/com/example/restservicecors/GreetingController.java @@ -0,0 +1,29 @@ +package com.example.restservicecors; + +import java.util.concurrent.atomic.AtomicLong; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class GreetingController { + + private static final String template = "Hello, %s!"; + private final AtomicLong counter = new AtomicLong(); + + @CrossOrigin(origins = "http://localhost:9000") + @GetMapping("/greeting") + public Greeting greeting(@RequestParam(required=false, defaultValue="World") String name) { + System.out.println("==== in greeting ===="); + return new Greeting(counter.incrementAndGet(), String.format(template, name)); + } + + @GetMapping("/greeting-javaconfig") + public Greeting greetingWithJavaconfig(@RequestParam(required=false, defaultValue="World") String name) { + System.out.println("==== in greeting ===="); + return new Greeting(counter.incrementAndGet(), String.format(template, name)); + } + +} diff --git a/complete/src/main/java/com/example/restservicecors/RestServiceCorsApplication.java b/complete/src/main/java/com/example/restservicecors/RestServiceCorsApplication.java new file mode 100644 index 0000000..5c16c38 --- /dev/null +++ b/complete/src/main/java/com/example/restservicecors/RestServiceCorsApplication.java @@ -0,0 +1,26 @@ +package com.example.restservicecors; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@SpringBootApplication +public class RestServiceCorsApplication { + + public static void main(String[] args) { + SpringApplication.run(RestServiceCorsApplication.class, args); + } + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/greeting-javaconfig").allowedOrigins("http://localhost:9000"); + } + }; + } + +} diff --git a/complete/src/test/java/com/example/restservicecors/GreetingIntegrationTests.java b/complete/src/test/java/com/example/restservicecors/GreetingIntegrationTests.java new file mode 100644 index 0000000..0b50191 --- /dev/null +++ b/complete/src/test/java/com/example/restservicecors/GreetingIntegrationTests.java @@ -0,0 +1,49 @@ +package com.example.restservicecors; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.RequestEntity; +import org.springframework.http.ResponseEntity; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +public class GreetingIntegrationTests { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void corsWithAnnotation() throws Exception { + ResponseEntity entity = this.restTemplate.exchange( + RequestEntity.get(uri("/greeting")).header(HttpHeaders.ORIGIN, "http://localhost:9000").build(), + Greeting.class); + assertEquals(HttpStatus.OK, entity.getStatusCode()); + assertEquals("http://localhost:9000", entity.getHeaders().getAccessControlAllowOrigin()); + Greeting greeting = entity.getBody(); + assertEquals("Hello, World!", greeting.getContent()); + } + + @Test + public void corsWithJavaconfig() { + ResponseEntity entity = this.restTemplate.exchange( + RequestEntity.get(uri("/greeting-javaconfig")).header(HttpHeaders.ORIGIN, "http://localhost:9000").build(), + Greeting.class); + assertEquals(HttpStatus.OK, entity.getStatusCode()); + assertEquals("http://localhost:9000", entity.getHeaders().getAccessControlAllowOrigin()); + Greeting greeting = entity.getBody(); + assertEquals("Hello, World!", greeting.getContent()); + } + + private URI uri(String path) { + return restTemplate.getRestTemplate().getUriTemplateHandler().expand(path); + } + +} diff --git a/complete/target/classes/com/example/restservicecors/Greeting.class b/complete/target/classes/com/example/restservicecors/Greeting.class new file mode 100644 index 0000000..fa52160 Binary files /dev/null and b/complete/target/classes/com/example/restservicecors/Greeting.class differ diff --git a/complete/target/classes/com/example/restservicecors/GreetingController.class b/complete/target/classes/com/example/restservicecors/GreetingController.class new file mode 100644 index 0000000..e57a458 Binary files /dev/null and b/complete/target/classes/com/example/restservicecors/GreetingController.class differ diff --git a/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication$1.class b/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication$1.class new file mode 100644 index 0000000..7deb639 Binary files /dev/null and b/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication$1.class differ diff --git a/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication.class b/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication.class new file mode 100644 index 0000000..66c5c14 Binary files /dev/null and b/complete/target/classes/com/example/restservicecors/RestServiceCorsApplication.class differ diff --git a/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 0000000..63f6ed3 --- /dev/null +++ b/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,4 @@ +com\example\restservicecors\RestServiceCorsApplication.class +com\example\restservicecors\RestServiceCorsApplication$1.class +com\example\restservicecors\Greeting.class +com\example\restservicecors\GreetingController.class diff --git a/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 0000000..6adfea8 --- /dev/null +++ b/complete/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,3 @@ +C:\projects\simpsons-coding-challenge\complete\src\main\java\com\example\restservicecors\RestServiceCorsApplication.java +C:\projects\simpsons-coding-challenge\complete\src\main\java\com\example\restservicecors\Greeting.java +C:\projects\simpsons-coding-challenge\complete\src\main\java\com\example\restservicecors\GreetingController.java diff --git a/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst new file mode 100644 index 0000000..5e2f435 --- /dev/null +++ b/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst @@ -0,0 +1 @@ +com\example\restservicecors\GreetingIntegrationTests.class diff --git a/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst new file mode 100644 index 0000000..98c5cad --- /dev/null +++ b/complete/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst @@ -0,0 +1 @@ +C:\projects\gs-rest-service-cors\complete\src\test\java\com\example\restservicecors\GreetingIntegrationTests.java diff --git a/complete/target/test-classes/com/example/restservicecors/GreetingIntegrationTests.class b/complete/target/test-classes/com/example/restservicecors/GreetingIntegrationTests.class new file mode 100644 index 0000000..f7a5355 Binary files /dev/null and b/complete/target/test-classes/com/example/restservicecors/GreetingIntegrationTests.class differ diff --git a/data/quotes.json b/data/quotes.json new file mode 100644 index 0000000..bbf09e7 --- /dev/null +++ b/data/quotes.json @@ -0,0 +1,147 @@ +{ + "data": [{ + "_id": "59edff64d9be8f7aa11e0c44", + "character": "59edee68706374dfa957842f", + "firstName": "Homer", + "lastName": "Simpson", + "picture": "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + "age": 43, + "phrase": "Wait a minute. Bart’s teacher is named ‘Krabappel’? Oh, I’ve been calling her ‘Crandall.’ Why didn’t anyone tell me? Ohhh, I’ve been making an idiot out of myself!" + }, + { + "_id": "59edff6492d619b4a933a56b", + "character": "59edee68706374dfa957842f", + "firstName": "Homer", + "lastName": "Simpson", + "picture": "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + "age": 43, + "phrase": "Now we play the waiting game…Ahh, the waiting game sucks. Let’s play Hungry Hungry Hippos!" + }, + { + "_id": "59edff6477ac8539e526682b", + "character": "59edee689509e51682ff8e02", + "firstName": "Marge", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + "age": 40, + "phrase": "Go out on a Tuesday? Who am I, Charlie Sheen?" + }, + { + "_id": "59edff647cf388e225bc5f4e", + "character": "59edee689509e51682ff8e02", + "firstName": "Marge", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + "age": 40, + "phrase": "I brought you a tuna sandwich. They say it's brain food. I guess because there's so much dolphin in it, and you know how smart they are." + }, + { + "_id": "59edff64ac4dccc4b0e35784", + "character": "59edee68eff3f80413c136f8", + "firstName": "Lisa", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/1/12/Lisa_Simpson-0.png/revision/latest?cb=20161027220133", + "age": 10, + "phrase": "I’d be mortified if someone ever made a lousy product with the Simpson name on it." + }, + { + "_id": "59edff6419f9d6df24d593fe", + "character": "59edee68eff3f80413c136f8", + "firstName": "Lisa", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/1/12/Lisa_Simpson-0.png/revision/latest?cb=20161027220133", + "age": 10, + "phrase": "You monster! You monster!" + }, + { + "_id": "59edff6403042a54f6038044", + "character": "59edee683406c7834ee7cdd8", + "firstName": "Bart", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/a/ab/BartSimpson.jpg/revision/latest?cb=20141101133153", + "age": 12, + "phrase": "There’s only one thing to do at a moment like this: strut!" + }, + { + "_id": "59edff643d4737e47c71835c", + "character": "59edee683406c7834ee7cdd8", + "firstName": "Bart", + "lastName": "Simpson", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/a/ab/BartSimpson.jpg/revision/latest?cb=20141101133153", + "age": 12, + "phrase": "Aren’t we forgetting the true meaning of Christmas: the birth of Santa." + }, + { + "_id": "59edff641b4c1f62aebe1e4d", + "character": "59edee68b4b101bef064bf11", + "firstName": "Hugo", + "lastName": "Simpson", + "picture": "https://static.simpsonswiki.com/images/thumb/1/1a/Hugo_Simpson.png/200px-Hugo_Simpson.png", + "age": 12, + "phrase": "I made a Pigeon-rat." + }, + { + "_id": "59edff643fbeca90867aa34d", + "character": "59edee68b4b101bef064bf11", + "firstName": "Hugo", + "lastName": "Simpson", + "picture": "https://static.simpsonswiki.com/images/thumb/1/1a/Hugo_Simpson.png/200px-Hugo_Simpson.png", + "age": 12, + "phrase": "Am I? Well, perhaps we're all a little crazy. I know I am. I went mad after they tore us apart, but I'll be sane… once I sew us back together." + }, + { + "_id": "59edff6494f9aef192ef4813", + "character": "59edee68874eb2fa23678344", + "firstName": "Ned", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/84/Ned_Flanders.png/revision/latest?cb=20100513160156", + "age": 50, + "phrase": "Diddly" + }, + { + "_id": "59edff64e6597a07e8e5dd33", + "character": "59edee68874eb2fa23678344", + "firstName": "Ned", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/8/84/Ned_Flanders.png/revision/latest?cb=20100513160156", + "age": 50, + "phrase": "I give you the jury of the damned. Benedict Arnold, Lizzie Borden, Richard Nixon..." + }, + { + "_id": "59edff6458b6a68631660120", + "character": "59edee68efd448eefb265420", + "firstName": "Rod", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/e/e6/Rod_Flanders2.png/revision/latest?cb=20140817110904", + "age": 13, + "phrase": "We just move one space at a time. It's less fun that way." + }, + { + "_id": "59edff646c740f1218a30a06", + "character": "59edee68efd448eefb265420", + "firstName": "Rod", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/e/e6/Rod_Flanders2.png/revision/latest?cb=20140817110904", + "age": 13, + "phrase": "Thank you, door! " + }, + { + "_id": "59edff644abadfc108cac2a6", + "character": "59edee68ea392a3947485d41", + "firstName": "Todd", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/1/18/Todd_Flanders.png/revision/latest?cb=20131223200228", + "age": 12, + "phrase": "Daddy says dice are wicked." + }, + { + "_id": "59edff64fd7790417d74b3a1", + "character": "59edee682c7acf7bfac7e66b", + "firstName": "Maude", + "lastName": "Flanders", + "picture": "https://vignette.wikia.nocookie.net/simpsons/images/9/95/Maude_Flanders.png/revision/latest/scale-to-width-down/286?cb=20170923223722", + "age": 49, + "phrase": "Oh, I don't care for the speed, but I can't get enough of that safety gear - helmets, roll bars, caution flags..." + } + ] +} diff --git a/tut-rest/.mvn/wrapper/MavenWrapperDownloader.java b/tut-rest/.mvn/wrapper/MavenWrapperDownloader.java new file mode 100644 index 0000000..02d39d8 --- /dev/null +++ b/tut-rest/.mvn/wrapper/MavenWrapperDownloader.java @@ -0,0 +1,117 @@ +/* + * Copyright 2007-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import java.net.*; +import java.io.*; +import java.nio.channels.*; +import java.util.Properties; + +public class MavenWrapperDownloader { + + private static final String WRAPPER_VERSION = "0.5.6"; + /** + * Default URL to download the maven-wrapper.jar from, if no 'downloadUrl' is provided. + */ + private static final String DEFAULT_DOWNLOAD_URL = "https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/" + + WRAPPER_VERSION + "/maven-wrapper-" + WRAPPER_VERSION + ".jar"; + + /** + * Path to the maven-wrapper.properties file, which might contain a downloadUrl property to + * use instead of the default one. + */ + private static final String MAVEN_WRAPPER_PROPERTIES_PATH = + ".mvn/wrapper/maven-wrapper.properties"; + + /** + * Path where the maven-wrapper.jar will be saved to. + */ + private static final String MAVEN_WRAPPER_JAR_PATH = + ".mvn/wrapper/maven-wrapper.jar"; + + /** + * Name of the property which should be used to override the default download url for the wrapper. + */ + private static final String PROPERTY_NAME_WRAPPER_URL = "wrapperUrl"; + + public static void main(String args[]) { + System.out.println("- Downloader started"); + File baseDirectory = new File(args[0]); + System.out.println("- Using base directory: " + baseDirectory.getAbsolutePath()); + + // If the maven-wrapper.properties exists, read it and check if it contains a custom + // wrapperUrl parameter. + File mavenWrapperPropertyFile = new File(baseDirectory, MAVEN_WRAPPER_PROPERTIES_PATH); + String url = DEFAULT_DOWNLOAD_URL; + if(mavenWrapperPropertyFile.exists()) { + FileInputStream mavenWrapperPropertyFileInputStream = null; + try { + mavenWrapperPropertyFileInputStream = new FileInputStream(mavenWrapperPropertyFile); + Properties mavenWrapperProperties = new Properties(); + mavenWrapperProperties.load(mavenWrapperPropertyFileInputStream); + url = mavenWrapperProperties.getProperty(PROPERTY_NAME_WRAPPER_URL, url); + } catch (IOException e) { + System.out.println("- ERROR loading '" + MAVEN_WRAPPER_PROPERTIES_PATH + "'"); + } finally { + try { + if(mavenWrapperPropertyFileInputStream != null) { + mavenWrapperPropertyFileInputStream.close(); + } + } catch (IOException e) { + // Ignore ... + } + } + } + System.out.println("- Downloading from: " + url); + + File outputFile = new File(baseDirectory.getAbsolutePath(), MAVEN_WRAPPER_JAR_PATH); + if(!outputFile.getParentFile().exists()) { + if(!outputFile.getParentFile().mkdirs()) { + System.out.println( + "- ERROR creating output directory '" + outputFile.getParentFile().getAbsolutePath() + "'"); + } + } + System.out.println("- Downloading to: " + outputFile.getAbsolutePath()); + try { + downloadFileFromURL(url, outputFile); + System.out.println("Done"); + System.exit(0); + } catch (Throwable e) { + System.out.println("- Error downloading"); + e.printStackTrace(); + System.exit(1); + } + } + + private static void downloadFileFromURL(String urlString, File destination) throws Exception { + if (System.getenv("MVNW_USERNAME") != null && System.getenv("MVNW_PASSWORD") != null) { + String username = System.getenv("MVNW_USERNAME"); + char[] password = System.getenv("MVNW_PASSWORD").toCharArray(); + Authenticator.setDefault(new Authenticator() { + @Override + protected PasswordAuthentication getPasswordAuthentication() { + return new PasswordAuthentication(username, password); + } + }); + } + URL website = new URL(urlString); + ReadableByteChannel rbc; + rbc = Channels.newChannel(website.openStream()); + FileOutputStream fos = new FileOutputStream(destination); + fos.getChannel().transferFrom(rbc, 0, Long.MAX_VALUE); + fos.close(); + rbc.close(); + } + +} \ No newline at end of file diff --git a/tut-rest/.mvn/wrapper/maven-wrapper.jar b/tut-rest/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000..2cc7d4a Binary files /dev/null and b/tut-rest/.mvn/wrapper/maven-wrapper.jar differ diff --git a/tut-rest/.mvn/wrapper/maven-wrapper.properties b/tut-rest/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..0c675e6 --- /dev/null +++ b/tut-rest/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.6.3/apache-maven-3.6.3-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar \ No newline at end of file diff --git a/tut-rest/.travis.yml b/tut-rest/.travis.yml new file mode 100644 index 0000000..83baa3e --- /dev/null +++ b/tut-rest/.travis.yml @@ -0,0 +1,6 @@ +language: java +jdk: +- openjdk8 +notifications: + slack: + secure: YwQz3uubmEHRiuzVqoJ694o4iPDrG5QWswwAyAn8gy1VW72gG4FgQcgqHS53yCd3Xd4cG8NHS/aS0iyfWAGpi3HLguarhtPi1m6mbXxZPFjfCeNz1Qj7tZ5iuHmMquJy8oBvwCEe5kOMBlc0GtwpYmFtZR2I9nNE1qwC5vjcWSs= diff --git a/tut-rest/LICENSE.code.txt b/tut-rest/LICENSE.code.txt new file mode 100644 index 0000000..4b5cde9 --- /dev/null +++ b/tut-rest/LICENSE.code.txt @@ -0,0 +1,16 @@ + All code in this repository is: + ======================================================================= + Copyright (c) 2013 GoPivotal, Inc. All Rights Reserved + + 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. + diff --git a/tut-rest/LICENSE.writing.txt b/tut-rest/LICENSE.writing.txt new file mode 100644 index 0000000..9d21229 --- /dev/null +++ b/tut-rest/LICENSE.writing.txt @@ -0,0 +1 @@ +Except where otherwise noted, this work is licensed under https://creativecommons.org/licenses/by-nd/3.0/ diff --git a/tut-rest/README.adoc b/tut-rest/README.adoc new file mode 100644 index 0000000..e7327b8 --- /dev/null +++ b/tut-rest/README.adoc @@ -0,0 +1,1009 @@ +--- +tags: [rest, hateoas, hypermedia, security, testing, oauth] +projects: [spring-framework, spring-hateoas, spring-security, spring-security-oauth] +--- +:toc: +:project_id: tut-rest +:icons: font +:source-highlighter: prettify +:javaee-api-root: https://docs.oracle.com/javaee/7/api/ +:image-width: 500 +:book-root: . + += Building REST Services with Spring + +REST has quickly become the de-facto standard for building web services on the web because they're easy to build and easy to consume. + +There's a much larger discussion to be had about how REST fits in the world of microservices, but - for this tutorial - let's just look at building RESTful services. + +Why REST? REST embraces the precepts of the web, including its architecture, benefits, and everything else. This is no surprise given its author, Roy Fielding, was involved +in probably a dozen specs which govern how the web operates. + +What benefits? The web and its core protocol, HTTP, provide a stack of features: + +* Suitable actions (`GET`, `POST`, `PUT`, `DELETE`, ...) +* Caching +* Redirection and forwarding +* Security (encryption and authentication) + +These are all critical factors on building resilient services. But that is not all. The web is built out of lots of tiny specs, hence it's been able to evolve easily, without getting bogged down in "standards wars". + +Developers are able to draw upon 3rd party toolkits that implement these diverse specs and instantly have both client and server technology at their fingertips. + +So building on top of HTTP, REST APIs provide the means to build flexible APIs that can: + +* Support backward compatibility +* Evolvable APIs +* Scaleable services +* Securable services +* A spectrum of stateless to stateful services + +What's important to realize is that REST, however ubiquitous, is not a standard, _per se_, but an approach, a style, a set of _constraints_ on your architecture that can help you build web-scale systems. In this tutorial +we will use the Spring portfolio to build a RESTful service while leveraging the stackless features of REST. + +== Getting Started + +As we work through this tutorial, we'll use https://spring.io/projects/spring-boot[Spring Boot]. Go to https://start.spring.io/[Spring Initializr] and select the following: + +* Web +* JPA +* H2 +* Lombok + +Then choose "Generate Project". A `.zip` will download. Unzip it. Inside you'll find a simple, Maven-based project including a `pom.xml` build file (NOTE: You _can_ use Gradle. The examples in this tutorial will be Maven based.) + +Spring Boot can work with any IDE. You can use Eclipse, IntelliJ IDEA, Netbeans, etc. https://spring.io/tools/[The Spring Tool Suite] is an open-source, Eclipse-based IDE distribution that provides a superset of the Java EE distribution of Eclipse. It includes features that making working with Spring applications even easier. It is, by no means, required. But consider it if you want that extra *oomph* for your keystrokes. Here's a video demonstrating how to get started with STS and Spring Boot. This is a general introduction to familiarize you with the tools. + +If you pick up IntelliJ IDEA as your IDE for this tutorial, you have to install lombok plugin. In order to see how we install plugins in IntelliJ IDEA please have a look at https://www.jetbrains.com/help/idea/managing-plugins.html[managing-plugins]. After this you have to ensure that "Enable annotation processing" checkbox is ticked under: Preferences -> Compiler -> Annotation Processors, as it is described https://stackoverflow.com/questions/14866765/building-with-lomboks-slf4j-and-intellij-cannot-find-symbol-log + +video::p8AdyMlpmPk[youtube] + +== The Story so Far... + +Let's start off with the simplest thing we can construct. In fact, to make it as simple as possible, we can even leave out the concepts of REST. (Later on, we'll add REST to understand the difference.) + +Our example models a simple payroll service that manages the employees of a company. Simply put, you need to store employee objects in an H2 in-memory database, and access them via JPA. This will be wrapped with a Spring MVC layer to access remotely. + +.nonrest/src/main/java/payroll/Employee.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/Employee.java[] +---- + +Despite being small, this Java class contains much: + +* `@Data` is a Lombok annotation to create all the getters, setters, `equals`, `hash`, and `toString` methods, based on the fields. +* `@Entity` is a JPA annotation to make this object ready for storage in a JPA-based data store. +* `id`, `name`, and `role` are the attribute for our domain object, the first being marked with more JPA annotations to indicate it's the primary key and automatically populated by the JPA provider. +* a custom constructor is created when we need to create a new instance, but don't yet have an id. + +With this domain object definition, we can now turn to https://spring.io/guides/gs/accessing-data-jpa/[Spring Data JPA] to handle the tedious database interactions. +Spring Data repositories are interfaces with methods supporting reading, updating, deleting, and creating records against a back end data store. Some repositories also support data paging, and sorting, where appropriate. Spring Data synthesizes implementations based on conventions found in the naming of the methods in the interface. + +NOTE: There are multiple repository implementations besides JPA. You can use Spring Data MongoDB, Spring Data GemFire, Spring Data Cassandra, etc. For this tutorial, we'll stick with JPA. + +.nonrest/src/main/java/payroll/EmployeeRepository.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/EmployeeRepository.java[] +---- + +This interface extends Spring Data JPA's `JpaRepository`, specifying the domain type as `Employee` and the id type as `Long`. This interface, though empty on the surface, packs a punch given it supports: + +* Creating new instances +* Updating existing ones +* Deleting +* Finding (one, all, by simple or complex properties) + +Spring Data's https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories[repository solution] makes it possible to sidestep data store specifics and instead solve a majority of problems using domain-specific terminology. + +Believe it or not, this is enough to launch an application! A Spring Boot application is, at a minimum, a `public static void main` entry-point and the `@SpringBootApplication` annotation. This tells Spring Boot to help out, wherever possible. + +.nonrest/src/main/java/payroll/PayrollApplication.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/PayrollApplication.java[] +---- + +`@SpringBootApplication` is a meta-annotation that pulls in *component scanning*, *autoconfiguration*, and *property support*. We won't dive +into the details of Spring Boot in this tutorial, but in essence, it will fire up a servlet container and serve up our service. + +Nevertheless, an application with no data isn't very interesting, so let's preload it. The follow class will get loaded automatically by Spring: + +.nonrest/src/main/java/payroll/LoadDatabase.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/LoadDatabase.java[] +---- + +What happens when it gets loaded? + +* Spring Boot will run ALL `CommandLineRunner` beans once the application context is loaded. +* This runner will request a copy of the `EmployeeRepository` you just created. +* Using it, it will create two entities and store them. +* `@Slf4j` is a Lombok annotation to autocreate an Slf4j-based `LoggerFactory` as `log`, allowing us to log these newly created "employees". + +Right-click and *Run* `PayRollApplication`, and this is what you get: + +.Fragment of console output showing preloading of data +---- +... +2018-08-09 11:36:26.169 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar) +2018-08-09 11:36:26.174 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=2, name=Frodo Baggins, role=thief) +... +---- + +This isn't the *whole* log, but just the key bits of preloading data. (Indeed, check out the whole console. It's glorious.) + +== HTTP is the Platform + +To wrap your repository with a web layer, you must turn to Spring MVC. Thanks to Spring Boot, there is little in infrastructure to code. Instead, we can focus on actions: + +.nonrest/src/main/java/payroll/EmployeeController.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/EmployeeController.java[] +---- + +* `@RestController` indicates that the data returned by each method will be written straight into the response body instead of rendering a template. +* An `EmployeeRepository` is injected by constructor into the controller. +* We have routes for each operations (`@GetMapping`, `@PostMapping`, `@PutMapping` and `@DeleteMapping`, corresponding to HTTP `GET`, `POST`, `PUT`, and `DELETE` calls). (NOTE: It's useful to read each method and understand what they do.) +* `EmployeeNotFoundException` is an exception used to indicate when an employee is looked up but not found. + +.nonrest/src/main/java/payroll/EmployeeNotFoundException.java +[source,java,tabsize=2] +---- +include::nonrest/src/main/java/payroll/EmployeeNotFoundException.java[] +---- + +When an `EmployeeNotFoundException` is thrown, this extra tidbit of Spring MVC configuration is used to render an *HTTP 404*: + +.nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java +[source,java,tabsize=2,indent=0] +---- +include::nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java[] +---- + +* `@ResponseBody` signals that this advice is rendered straight into the response body. +* `@ExceptionHandler` configures the advice to only respond if an `EmployeeNotFoundException` is thrown. +* `@ResponseStatus` says to issue an `HttpStatus.NOT_FOUND`, i.e. an *HTTP 404*. +* The body of the advice generates the content. In this case, it gives the message of the exception. + +To launch the application, either right-click the `public static void main` in `PayRollApplication` and select *Run* from your IDE, or: + +Spring Initializr uses maven wrapper so type this: + +---- +$ ./mvnw clean spring-boot:run +---- + +Alternatively using your installed maven version type this: + +---- +$ mvn clean spring-boot:run +---- + +When the app starts, we can immediately interrogate it. + +---- +$ curl -v localhost:8080/employees +---- + +This will yield: + +---- +* Trying ::1... +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> GET /employees HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 200 +< Content-Type: application/json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Thu, 09 Aug 2018 17:58:00 GMT +< +* Connection #0 to host localhost left intact +[{"id":1,"name":"Bilbo Baggins","role":"burglar"},{"id":2,"name":"Frodo Baggins","role":"thief"}] +---- + +Here you can see the pre-loaded data, in a compacted format. + +If you try and query a user that doesn't exist... + +---- +$ curl -v localhost:8080/employees/99 +---- + +You get... + +---- +* Trying ::1... +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> GET /employees/99 HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 404 +< Content-Type: text/plain;charset=UTF-8 +< Content-Length: 26 +< Date: Thu, 09 Aug 2018 18:00:56 GMT +< +* Connection #0 to host localhost left intact +Could not find employee 99 +---- + +This message nicely shows an *HTTP 404* error with the custom message *Could not find employee 99*. + +It's not hard to show the currently coded interactions... + +---- +$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' +---- + +Creates a new `Employee` record, and then sends the content back to us: + +---- +{"id":3,"name":"Samwise Gamgee","role":"gardener"} +---- + +You can alter the user: + +---- +$ curl -X PUT localhost:8080/employees/3 -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}' +---- + +Updates the user: + +---- +{"id":3,"name":"Samwise Gamgee","role":"ring bearer"} +---- + +WARNING: Depending on how you construct your service can have significant impacts. In this situation, *replace* is a better description than *update*. For example, if the name was NOT provided, it would instead get nulled out. + +And you can delete... + +---- +$ curl -X DELETE localhost:8080/employees/3 +$ curl localhost:8080/employees/3 +Could not find employee 3 +---- + +This is all well and good, but do we have RESTful service yet? (If you didn't catch the hint, the answer is no.) + +What's missing? + +== What makes something RESTful? + +So far, you have a web-based service that handles the core operations involving employee data. But that's not enough to make things "RESTful". + +* Pretty URLs like /employees/3 aren't REST. +* Merely using `GET`, `POST`, etc. aren't REST. +* Having all the CRUD operations laid out aren't REST. + +In fact, what we have built so far is better described as *RPC* (*Remote Procedure Call*). That's because there is no way to know how to interact with this service. If you published this today, you'd also have to write a document or host a developer's portal somewhere with all the details. + +This statement of Roy Fielding may further lend a clue to the difference between *REST* and *RPC*: + +[quote, Roy Fielding, https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven] +____ +I am getting frustrated by the number of people calling any HTTP-based interface a REST API. Today’s example is the SocialSite REST API. That is RPC. It screams RPC. There is so much coupling on display that it should be given an X rating. + +What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed? +____ + + +The side effect of NOT including hypermedia in our representations is that clients MUST hard code URIs to navigate the API. This leads to the same brittle nature that predated the rise of e-commerce on the web. It's a signal that our JSON output needs a little help. + +Introducing https://spring.io/projects/spring-hateoas[Spring HATEOAS], a Spring project aimed at helping you write hypermedia-driven outputs. To upgrade your service to being RESTful, add this to your build: + +.Adding Spring HATEOAS to pom.xml +[source,xml,indent=0] +---- +include::rest/pom.xml[tag=spring-hateoas] +---- + +This tiny library will give us the constructs to define a RESTful service and then render it in an acceptable format for client consumption. + +A critical ingredient to any RESTful service is adding https://tools.ietf.org/html/rfc5988[links] to relevant operations. To make your controller more RESTful, add links like this: + +.Getting a single item resource +[source,java,tabsize=2,indent=0] +---- +include::rest/src/main/java/payroll/EmployeeController.java[tag=get-single-item] +---- + +.Relevant import statements +[source,java,tabsize=2,indent=0] +---- +include::rest/src/main/java/payroll/EmployeeController.java[tag=hateoas-imports] +---- + + +This is very similar to what we had before, but a few things have changed: + +* The return type of the method has changed from `Employee` to `EntityModel`. `EntityModel` is a generic container from Spring HATEOAS that includes not only the data but a collection of links. +* `linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()` asks that Spring HATEOAS build a link to the `EmployeeController` 's `one()` method, and flag it as a https://www.iana.org/assignments/link-relations/link-relations.xhtml[self] link. +* `linkTo(methodOn(EmployeeController.class).all()).withRel("employees")` asks Spring HATEOAS to build a link to the aggregate root, `all()`, and call it "employees". + +What do we mean by "build a link"? One of Spring HATEOAS's core types is `Link`. It includes a *URI* and a *rel* (relation). +Links are what empower the web. Before the World Wide Web, other document systems would render information or links, but +it was the linking of documents WITH data that stitched the web together. + +Roy Fielding encourages building APIs with the same techniques that made the web successful, and links are one of them. + +If you restart the application and query the employee record of *Bilbo*, you'll get a slightly different response than earlier: + +.RESTful representation of a single employee +[source,javascript] +---- +{ + "id": 1, + "name": "Bilbo Baggins", + "role": "burglar", + "_links": { + "self": { + "href": "http://localhost:8080/employees/1" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } +} +---- + +This decompressed output shows not only the data elements you saw earlier (`id`, `name` and `role`), but also a `_links` entry containing two URIs. This entire document is formatted using http://stateless.co/hal_specification.html[HAL]. + +HAL is a lightweight https://tools.ietf.org/html/draft-kelly-json-hal-08[mediatype] that allows encoding not just data but also hypermedia controls, alerting consumers to other parts of the API they can navigate toward. In this case, +there is a "self" link (kind of like a `this` statement in code) along with a link back to the *aggregate root*. + +To make the aggregate root ALSO more RESTful, you want to include top level links while ALSO including any RESTful components within: + +.Getting an aggregate root resource +[source,java,tabsize=2,indent=0] +---- +include::rest/src/main/java/payroll/EmployeeController.java[tag=get-aggregate-root] +---- + +Wow! That method, which used to just be `repository.findAll()` has grown big! Let's unpack it. + +`CollectionModel<>` is another Spring HATEOAS container aimed at encapsulating collections. It, too, also lets you include links. +Don't let that first statement slip by. What does "encapsulating collections" mean? Collections of employees? + +Not quite. + +Since we're talking REST, it should encapsulate collections of *employee resources*. + +That's why you fetch all the employees, but then transform them into a list of `EntityModel` objects. (Thanks Java 8 Stream API!) + +If you restart the application and fetch the aggregate root, you can see what this looks like. + +.RESTful representation of a collection of employee resources +[source,javascript] +---- +{ + "_embedded": { + "employeeList": [ + { + "id": 1, + "name": "Bilbo Baggins", + "role": "burglar", + "_links": { + "self": { + "href": "http://localhost:8080/employees/1" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } + }, + { + "id": 2, + "name": "Frodo Baggins", + "role": "thief", + "_links": { + "self": { + "href": "http://localhost:8080/employees/2" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } + } + ] + }, + "_links": { + "self": { + "href": "http://localhost:8080/employees" + } + } +} +---- + +For this aggregate root, which serves up a collection of employee resources, there is a top-level *"self"* link. The *"collection"* +is listed underneath the *"_embedded"* section. This is how HAL represents collections. + +And each individual member of the collection has their information as well as related links. + +What is the point of adding all these links? It makes it possible to evolve REST services over time. Existing links can be maintained +while new links are added in the future. Newer clients may take advantage of the new links, while legacy clients can sustain themselves +on the old links. This is especially helpful if services get relocated and moved around. As long as the link structure is maintained, +clients can STILL find and interact with things. + +== Simplifying Link Creation + +Did you notice the repetition in single employee link creation? The code to provide a single link to an employee as well as an "employees" +link to the aggregate root was shown twice. If that raised your concern, good! There's a solution. + +Simply put, you need to define a function that converts `Employee` objects to `EntityModel` objects. While you could easily +code this method yourself, there are benefits down the road of implementing Spring HATEOAS's `RepresentationModelAssembler` interface. + +.evolution/src/main/java/payroll/EmployeeResourceAssembler.java +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeModelAssembler.java[] +---- + +This simple interface has one method: `toModel()`. It is based on converting a non-resource object (`Employee`) into a resource-based +object (`EntityModel`). + +All the code you saw earlier in the controller can be moved into this class. And by applying Spring Framework's `@Component`, this component +will be automatically created when the app starts. + +NOTE: Spring HATEOAS's abstract base class for all resources is `RepresentationModel`. But for simplicity, I recommend using `EntityModel` +as your mechanism to easily wrap all POJOs as resources. + +To leverage this assembler, you only have to alter the `EmployeeController` by injecting the assembler in the constructor. + +.Injecting EmployeeResourceAssembler into the controller +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=constructor] + + ... + +} +---- + +From here, you can use it in the single-item employee method: + +.Getting single item resource using the assembler +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=get-single-item] +---- + +This code is almost the same, except instead of creating the `EntityModel` instance here, you delegate it to the assembler. +Maybe that doesn't look like much? + +Applying the same thing in the aggregate root controller method is more impressive: + +.Getting aggregate root resource using the assembler +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=get-aggregate-root] +---- + +The code is, again, almost the same, however you get to replace all that `EntityModel` creation logic with `map(assembler::toModel)`. +Thanks to Java 8 method references, it's super easy to plug it in and simplify your controller. + +IMPORTANT: A key design goal of Spring HATEOAS is to make it easier to do The Right Thing(TM). In this scenario, adding hypermedia to your service without hard coding a thing. + +At this stage, you've created a Spring MVC REST controller that actually produces hypermedia-powered content! Clients that don't speak HAL can ignore the extra bits while consuming the pure data. Clients that DO speak HAL can navigate your empowered API. + +But that is not the only thing needed to build a truly RESTful service with Spring. + +== Evolving REST APIs + +With one additional library and a few lines of extra code, you have added hypermedia to your application. But that is not the only thing needed to make your service RESTful. An important facet of REST is the fact that it's neither a technology stack nor a single standard. + +REST is a collection of architectural constraints that when adopted make your application much more resilient. A key factor of +resilience is that when you make upgrades to your services, your clients don't suffer from downtime. + +In the "olden" days, upgrades were notorious for breaking clients. In other words, an upgrade to the server required an update +to the client. In this day and age, hours or even minutes of downtime spent doing an upgrade can cost millions in +lost revenue. + +Some companies require that you present management with a plan to minimize downtime. In the past, you could get away +with upgrading at 2:00 a.m. on a Sunday when load was at a minimum. But in today's Internet-based e-commerce with international +customers, such strategies are not as effective. + +SOAP-based services and CORBA-based services were incredibly brittle. It was hard to roll out a server that could support both old and new +clients. With REST-based practices, it's much easier. Especially using the Spring stack. + +Imagine this design problem: You've rolled out a system with this `Employee` -based record. The system is a major hit. You've sold +your system to countless enterprises. Suddenly, the need for an employee's name to be split into `firstName` and `lastName` arises. + +Uh oh. Didn't think of that. + +Before you open up the `Employee` class and replace the single field `name` with `firstName` and `lastName`, stop and think for a second. +Will that break any clients? How long will it take to upgrade them. Do you even control all the clients accessing your services? + +Downtime = lost money. Is management ready for that? + +There is an old strategy that precedes REST by years. + +[quote, Unknown] +Never delete a column in a database. + +You can always add columns (fields) to a database table. But don't take one away. The principle in RESTful services is the same. +Add new fields to your JSON representations, but don't take any away. Like this: + +.JSON that supports multiple clients +[source,javascript] +---- +{ + "id": 1, + "firstName": "Bilbo", + "lastName": "Baggins", + "role": "burglar", + "name": "Bilbo Baggins", + "_links": { + "self": { + "href": "http://localhost:8080/employees/1" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } +} +---- + +Notice how this format shows `firstName`, `lastName`, AND `name`? While it sports duplication of information, the purpose is to support +both old and new clients. That means you can upgrade the server without requiring clients upgrade at the same time. A good move +that should reduce downtime. + +And not only should you show this information in both the "old way" and the "new way", you should also process incoming data both ways. + +How? Simple. Like this: + +.Employee record that handles both "old" and "new" clients +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/Employee.java[] +---- + +This class is very similar to the previous version of `Employee`. Let's go over the changes: + +* Field `name` has been replaced by `firstName` and `lastName`. Lombok will generate getters and setters for those. +* A "virtual" getter for the old `name` property, `getName()` is defined. It uses the `firstName` and `lastName` fields to produce a value. +* A "virtual" setter for the old `name` property is also defined, `setName()`. It parses an incoming string and stores it into the proper fields. + +Of course not EVERY change to your API is as simple as splitting a string or merging two strings. But it's surely not impossible +to come up with a set of transforms for most scenarios, ehh? + +Another fine tuning is to ensure each of your REST methods returns a proper response. Update the POST method like this: + +.POST that handles "old" and "new" client requests +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=post] +---- + +* The new `Employee` object is saved as before. But the resulting object is wrapped using the `EmployeeModelAssembler`. +* Spring MVC's `ResponseEntity` is used to create an *HTTP 201 Created* status message. This type of response typically includes a *Location* response header, and we use the URI derived from the model's self-related link. +* Additionally, return the resource-based version of the saved object. + +With this tweak in place, you can use the same endpoint to create a new employee resource, and use the legacy `name` field: + +---- +$ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' +---- + +The output is shown below: + +---- +> POST /employees HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> Content-Type:application/json +> Content-Length: 46 +> +< Location: http://localhost:8080/employees/3 +< Content-Type: application/hal+json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Fri, 10 Aug 2018 19:44:43 GMT +< +{ + "id": 3, + "firstName": "Samwise", + "lastName": "Gamgee", + "role": "gardener", + "name": "Samwise Gamgee", + "_links": { + "self": { + "href": "http://localhost:8080/employees/3" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } +} +---- + +This not only has the resulting object rendered in HAL (both `name` as well as `firstName`/`lastName`), but also the *Location* header populated with `http://localhost:8080/employees/3`. +A hypermedia powered client could opt to "surf" to this new resource and proceed to interact with it. + +The PUT controller method needs similar tweaks: + +.Handling a PUT for different clients +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=put] +---- + +The `Employee` object built from the `save()` operation is then wrapped using the `EmployeeModelAssembler` into an `EntityModel` object. Using the `getRequiredLink()` method, you can retrieve the `Link` created by the `EmployeeModelAssembler` with a `SELF` rel. This method returns a `Link` which must be turned into a `URI` with the `toUri` method. + +Since we want a more detailed HTTP response code than *200 OK*, we will use Spring MVC's `ResponseEntity` wrapper. It has a handy +static method `created()` where we can plug in the resource's URI. +It's debatable if *HTTP 201 Created* carries the right semantics since we aren't necessarily "creating" a new resource. But it comes pre-loaded with a *Location* response header, so run with it. + +---- +$ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}' + +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> PUT /employees/3 HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> Content-Type:application/json +> Content-Length: 49 +> +< HTTP/1.1 201 +< Location: http://localhost:8080/employees/3 +< Content-Type: application/hal+json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Fri, 10 Aug 2018 19:52:56 GMT +{ + "id": 3, + "firstName": "Samwise", + "lastName": "Gamgee", + "role": "ring bearer", + "name": "Samwise Gamgee", + "_links": { + "self": { + "href": "http://localhost:8080/employees/3" + }, + "employees": { + "href": "http://localhost:8080/employees" + } + } +} +---- + +That employee resource has now been updated and the location URI sent back. Finally, update the DELETE operation suitably: + +.Handling DELETE requests +[source,java,tabsize=2,indent=0] +---- +include::evolution/src/main/java/payroll/EmployeeController.java[tag=delete] +---- + +This returns an *HTTP 204 No Content* response. + +---- +$ curl -v -X DELETE localhost:8080/employees/1 + +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> DELETE /employees/1 HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 204 +< Date: Fri, 10 Aug 2018 21:30:26 GMT +---- + +IMPORTANT: Making changes to the fields in the `Employee` class will require coordination with your database team, so +that they can properly migrate existing content into the new columns. + +You are now ready for an upgrade that will NOT disturb existing clients while newer clients can take advantage of the enhancements! + +By the way, are you worried about sending too much information over the wire? In some systems where every byte counts, +evolution of APIs may need to take a backseat. But don't pursue such premature optimization until you measure. + +== Building links into your REST API + +So far, you've built an evolvable API with bare bones links. To grow your API and better serve your clients, you need to embrace the concept of +*Hypermedia as the Engine of Application State*. + +What does that mean? In this section, you'll explore it in detail. + +Business logic inevitably builds up rules that involve processes. The risk of such systems is we often carry such server-side +logic into clients and build up strong coupling. REST is about breaking down such connections and minimizing such coupling. + +To show how to cope with state changes without triggering breaking changes in clients, imagine adding a system that fulfills orders. + +As a first step, define an `Order` record: + +.links/src/main/java/payroll/Order.java +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/Order.java[] +---- + +* The class requires a JPA `@Table` annotation changing the table's name to `CUSTOMER_ORDER` because `ORDER` is not a valid name for table. +* It includes a `description` field as well as a `status` field. + +Orders must go through a certain series of state transitions from the time a customer submits an order and it is either +fulfilled or cancelled. This can be captured as a Java `enum`: + +.links/src/main/java/payroll/Status.java +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/Status.java[] +---- + +This `enum` captures the various states an `Order` can occupy. For this tutorial, let's keep it simple. + +To support interacting with orders in the database, you must define a corresponding Spring Data repository: + +.Spring Data JPA's `JpaRepository` base interface +[source,java,tabsize=2] +---- +interface OrderRepository extends JpaRepository { +} +---- + +With this in place, you can now define a basic `OrderController`: + +.links/src/main/java/payroll/OrderController.java +[source,java,tabsize=2] +---- +include::links/src/main/java/payroll/OrderController.java[tag=main] +} +---- + +* It contains the same REST controller setup as the controllers you've built so far. +* It injects both an `OrderRepository` as well as a (not yet built) `OrderModelAssembler`. +* The first two Spring MVC routes handle the aggregate root as well as a single item `Order` resource request. +* The third Spring MVC route handles creating new orders, by starting them in the `IN_PROGRESS` state. +* All the controller methods return one of Spring HATEOAS's `RepresentationModel` subclasses to properly render hypermedia (or a wrapper around such a type). + +Before building the `OrderModelAssembler`, let's discuss what needs to happen. You are modeling the flow of states between +`Status.IN_PROGRESS`, `Status.COMPLETED`, and `Status.CANCELLED`. A natural thing when serving up such data to clients is to +let the clients make the decision on what it can do based on this payload. + +But that would be wrong. + +What happens when you introduce a new state in this flow? The placement of various buttons on the UI would probably be erroneous. + +What if you changed the name of each state, perhaps while coding international support and showing locale-specific text for each state? +That would most likely break all the clients. + +Enter *HATEOAS* or *Hypermedia as the Engine of Application State*. Instead of clients parsing the payload, give them links +to signal valid actions. Decouple state-based actions from the payload of data. In other words, when *CANCEL* and *COMPLETE* are valid actions, +dynamically add them to the list of links. Clients only need show users the corresponding buttons when the links exist. + +This decouples clients from having to know WHEN such actions are valid, reducing the risk of the server and its clients getting +out of sync on the logic of state transitions. + +Having already embraced the concept of Spring HATEOAS `ResourceAssembler` components, putting such logic in the `OrderModelAssembler` +would be the perfect place to capture this business rule: + +.links/src/main/java/payroll/OrderModelAssembler.java +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/OrderModelAssembler.java[] +---- + +This resource assembler always includes the *self* link to the single-item resource as well as a link back to the aggregate root. +But it also includes two conditional links to `OrderController.cancel(id)` as well as `OrderController.complete(id)` (not yet defined). These +links are ONLY shown when the order's status is `Status.IN_PROGRESS`. + +If clients can adopt HAL and the ability to read links instead of simply reading the data of plain old JSON, they can trade +in the need for domain knowledge about the order system. This naturally reduces coupling between client and server. And it +opens the door to tuning the flow of order fulfillment without breaking clients in the process. + +To round out order fulfillment, add the following to the `OrderController` for the `cancel` operation: + +.Creating a "cancel" operation in the OrderController +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/OrderController.java[tag=delete] +---- + +It checks the `Order` status before allowing it to be cancelled. If it's not a valid state, it returns a Spring HATEOAS `VndError`, +a hypermedia-supporting error container. If the transition is indeed valid, it transitions the `Order` to `CANCELLED`. + +And add this to the `OrderController` as well for order completion: + +.Creating a "complete" operation in the OrderController +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/OrderController.java[tag=complete] +---- + +This implements similar logic to prevent an `Order` status from being completed unless in the proper state. + +By adding a little extra initialization code to `LoadDatabase`: + +.Updating the database pre-loader +[source,java,tabsize=2,indent=0] +---- +include::links/src/main/java/payroll/LoadDatabase.java[tag=order] +---- + +...you can test things out! + +To use the newly minted order service, just perform a few operations: + +---- +$ curl -v http://localhost:8080/orders + +{ + "_embedded": { + "orderList": [ + { + "id": 3, + "description": "MacBook Pro", + "status": "COMPLETED", + "_links": { + "self": { + "href": "http://localhost:8080/orders/3" + }, + "orders": { + "href": "http://localhost:8080/orders" + } + } + }, + { + "id": 4, + "description": "iPhone", + "status": "IN_PROGRESS", + "_links": { + "self": { + "href": "http://localhost:8080/orders/4" + }, + "orders": { + "href": "http://localhost:8080/orders" + }, + "cancel": { + "href": "http://localhost:8080/orders/4/cancel" + }, + "complete": { + "href": "http://localhost:8080/orders/4/complete" + } + } + } + ] + }, + "_links": { + "self": { + "href": "http://localhost:8080/orders" + } + } +} +---- + +This HAL document immediately shows different links for each order, based upon its present state. + +* The first order, being *COMPLETED* only has the navigational links. The state transition links are not shown. +* The second order, being *IN_PROGRESS* additionally has the *cancel* link as well as the *complete* link. + +Try cancelling an order: + +---- +$ curl -v -X DELETE http://localhost:8080/orders/4/cancel + +> DELETE /orders/4/cancel HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 200 +< Content-Type: application/hal+json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Mon, 27 Aug 2018 15:02:10 GMT +< +{ + "id": 4, + "description": "iPhone", + "status": "CANCELLED", + "_links": { + "self": { + "href": "http://localhost:8080/orders/4" + }, + "orders": { + "href": "http://localhost:8080/orders" + } + } +} +---- + +This response shows an *HTTP 200* status code indicating it was successful. The response HAL document shows that order in its +new state (`CANCELLED`). And the state-altering links gone. + +If you try the same operation again... + +---- +$ curl -v -X DELETE http://localhost:8080/orders/4/cancel + +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> DELETE /orders/4/cancel HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 405 +< Content-Type: application/hal+json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Mon, 27 Aug 2018 15:03:24 GMT +< +{ + "logref": "Method not allowed", + "message": "You can't cancel an order that is in the CANCELLED status" +} +---- + +...you see an *HTTP 405 Method Not Allowed* response. *DELETE* has become an invalid operation. The `VndError` response +object clearly indicates that you aren't allowed to "cancel" an order already in the "CANCELLED" status. + +Additionally, trying to complete the same order also fails: + +---- +$ curl -v -X PUT localhost:8080/orders/4/complete + +* TCP_NODELAY set +* Connected to localhost (::1) port 8080 (#0) +> PUT /orders/4/complete HTTP/1.1 +> Host: localhost:8080 +> User-Agent: curl/7.54.0 +> Accept: */* +> +< HTTP/1.1 405 +< Content-Type: application/hal+json;charset=UTF-8 +< Transfer-Encoding: chunked +< Date: Mon, 27 Aug 2018 15:05:40 GMT +< +{ + "logref": "Method not allowed", + "message": "You can't complete an order that is in the CANCELLED status" +} +---- + +With all this in place, your order fulfillment service is capable of conditionally showing what operations are available. It +also guards against invalid operations. + +By leveraging the protocol of hypermedia and links, clients can be built sturdier and less likely to break simply because +of a change in the data. And Spring HATEOAS eases building the hypermedia you need to serve to your clients. + +== Summary + +Throughout this tutorial, you have engaged in various tactics to build REST API. As it turns out, REST isn't just about +pretty URIs and returning JSON instead of XML. + +Instead, the following tactics help make your services less likely to break existing clients you may or may not control: + +* Don't remove old fields. Instead, support them. +* Use rel-based links so clients don't have to hard code URIs. +* Retain old links as long as possible. Even if you have to change the URI, keep the rels so older clients have a path onto the newer features. +* Use links, not payload data, to instruct clients when various state-driving operations are available. + +It may appear to be a bit of effort to build up `RepresentationModelAssembler` implementations for each resource type and to use these components +in all of your controllers. But this extra bit of server-side setup (made easy thanks to Spring HATEOAS) can ensure the +clients you control (and more importantly, those you don't) can upgrade with ease as you evolve your API. + +This concludes our tutorial on how to build RESTful services using Spring. Each section of this tutorial is managed as a separate +subproject in a single github repo: + +* *nonrest* - Simple Spring MVC app with no hypermedia +* *rest* - Spring MVC + Spring HATEOAS app with HAL representations of each resource +* *evolution* - REST app where a field is evolved but old data is retained for backward compatibility +* *links* - REST app where conditional links are used to signal valid state changes to clients + +To view more examples of using Spring HATEOAS see https://github.com/spring-projects/spring-hateoas-examples. + +To do some more exploring check out the following video by Spring teammate Oliver Gierke: + +video::WDBUlu_lYas[youtube] + +include::https://raw.githubusercontent.com/spring-guides/getting-started-macros/master/footer.adoc[] diff --git a/tut-rest/evolution/pom.xml b/tut-rest/evolution/pom.xml new file mode 100644 index 0000000..9da9bc0 --- /dev/null +++ b/tut-rest/evolution/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + + org.springframework.guides + tut-rest + 0.0.1-SNAPSHOT + + + evolution + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-hateoas + + + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/tut-rest/evolution/src/main/java/payroll/Employee.java b/tut-rest/evolution/src/main/java/payroll/Employee.java new file mode 100644 index 0000000..c44ba42 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/Employee.java @@ -0,0 +1,35 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +class Employee { + + private @Id @GeneratedValue Long id; + private String firstName; + private String lastName; + private String role; + + Employee() {} + + Employee(String firstName, String lastName, String role) { + this.firstName = firstName; + this.lastName = lastName; + this.role = role; + } + + public String getName() { + return this.firstName + " " + this.lastName; + } + + public void setName(String name) { + String[] parts =name.split(" "); + this.firstName = parts[0]; + this.lastName = parts[1]; + } +} diff --git a/tut-rest/evolution/src/main/java/payroll/EmployeeController.java b/tut-rest/evolution/src/main/java/payroll/EmployeeController.java new file mode 100644 index 0000000..f3bf704 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/EmployeeController.java @@ -0,0 +1,103 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URISyntaxException; +import java.util.List; +import java.util.stream.Collectors; + +// tag::constructor[] +@RestController +class EmployeeController { + + private final EmployeeRepository repository; + + private final EmployeeModelAssembler assembler; + + EmployeeController(EmployeeRepository repository, + EmployeeModelAssembler assembler) { + + this.repository = repository; + this.assembler = assembler; + } + // end::constructor[] + + // Aggregate root + + // tag::get-aggregate-root[] + @GetMapping("/employees") + CollectionModel> all() { + + List> employees = repository.findAll().stream() + .map(assembler::toModel) + .collect(Collectors.toList()); + + return new CollectionModel<>(employees, + linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); + } + // end::get-aggregate-root[] + + // tag::post[] + @PostMapping("/employees") + ResponseEntity newEmployee(@RequestBody Employee newEmployee) throws URISyntaxException { + + EntityModel entityModel = assembler.toModel(repository.save(newEmployee)); + + return ResponseEntity + .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) + .body(entityModel); + } + // end::post[] + + // Single item + + // tag::get-single-item[] + @GetMapping("/employees/{id}") + EntityModel one(@PathVariable Long id) { + + Employee employee = repository.findById(id) + .orElseThrow(() -> new EmployeeNotFoundException(id)); + + return assembler.toModel(employee); + } + // end::get-single-item[] + + // tag::put[] + @PutMapping("/employees/{id}") + ResponseEntity replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) throws URISyntaxException { + + Employee updatedEmployee = repository.findById(id) + .map(employee -> { + employee.setName(newEmployee.getName()); + employee.setRole(newEmployee.getRole()); + return repository.save(employee); + }) + .orElseGet(() -> { + newEmployee.setId(id); + return repository.save(newEmployee); + }); + + EntityModel entityModel = assembler.toModel(updatedEmployee); + + return ResponseEntity + .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) + .body(entityModel); + } + // end::put[] + + // tag::delete[] + @DeleteMapping("/employees/{id}") + ResponseEntity deleteEmployee(@PathVariable Long id) { + + repository.deleteById(id); + + return ResponseEntity.noContent().build(); + } + // end::delete[] +} diff --git a/tut-rest/evolution/src/main/java/payroll/EmployeeModelAssembler.java b/tut-rest/evolution/src/main/java/payroll/EmployeeModelAssembler.java new file mode 100644 index 0000000..644b5f9 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/EmployeeModelAssembler.java @@ -0,0 +1,19 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +@Component +class EmployeeModelAssembler implements RepresentationModelAssembler> { + + @Override + public EntityModel toModel(Employee employee) { + + return new EntityModel<>(employee, + linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(), + linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); + } +} diff --git a/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundAdvice.java b/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundAdvice.java new file mode 100644 index 0000000..0e162f1 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class EmployeeNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(EmployeeNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String employeeNotFoundHandler(EmployeeNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundException.java b/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundException.java new file mode 100644 index 0000000..90ede89 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/EmployeeNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class EmployeeNotFoundException extends RuntimeException { + + EmployeeNotFoundException(Long id) { + super("Could not find employee " + id); + } +} diff --git a/tut-rest/evolution/src/main/java/payroll/EmployeeRepository.java b/tut-rest/evolution/src/main/java/payroll/EmployeeRepository.java new file mode 100644 index 0000000..dbeb9ec --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/EmployeeRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface EmployeeRepository extends JpaRepository { + +} diff --git a/tut-rest/evolution/src/main/java/payroll/LoadDatabase.java b/tut-rest/evolution/src/main/java/payroll/LoadDatabase.java new file mode 100644 index 0000000..fda09e0 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/LoadDatabase.java @@ -0,0 +1,20 @@ +package payroll; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +class LoadDatabase { + + @Bean + CommandLineRunner initDatabase(EmployeeRepository repository) { + return args -> { + log.info("Preloading " + repository.save(new Employee("Bilbo", "Baggins", "burglar"))); + log.info("Preloading " + repository.save(new Employee("Frodo", "Baggins", "thief"))); + }; + } +} diff --git a/tut-rest/evolution/src/main/java/payroll/PayrollApplication.java b/tut-rest/evolution/src/main/java/payroll/PayrollApplication.java new file mode 100644 index 0000000..3ef8fe6 --- /dev/null +++ b/tut-rest/evolution/src/main/java/payroll/PayrollApplication.java @@ -0,0 +1,12 @@ +package payroll; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PayrollApplication { + + public static void main(String... args) { + SpringApplication.run(PayrollApplication.class, args); + } +} diff --git a/tut-rest/links/pom.xml b/tut-rest/links/pom.xml new file mode 100644 index 0000000..6875709 --- /dev/null +++ b/tut-rest/links/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + org.springframework.guides + tut-rest + 0.0.1-SNAPSHOT + + + links + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-hateoas + + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/tut-rest/links/src/main/java/payroll/Employee.java b/tut-rest/links/src/main/java/payroll/Employee.java new file mode 100644 index 0000000..c44ba42 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/Employee.java @@ -0,0 +1,35 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +class Employee { + + private @Id @GeneratedValue Long id; + private String firstName; + private String lastName; + private String role; + + Employee() {} + + Employee(String firstName, String lastName, String role) { + this.firstName = firstName; + this.lastName = lastName; + this.role = role; + } + + public String getName() { + return this.firstName + " " + this.lastName; + } + + public void setName(String name) { + String[] parts =name.split(" "); + this.firstName = parts[0]; + this.lastName = parts[1]; + } +} diff --git a/tut-rest/links/src/main/java/payroll/EmployeeController.java b/tut-rest/links/src/main/java/payroll/EmployeeController.java new file mode 100644 index 0000000..46ad152 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/EmployeeController.java @@ -0,0 +1,103 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.IanaLinkRelations; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +// tag::constructor[] +@RestController +class EmployeeController { + + private final EmployeeRepository repository; + + private final EmployeeModelAssembler assembler; + private final OrderModelAssembler orderAssembler; + + EmployeeController(EmployeeRepository repository, + EmployeeModelAssembler assembler, + OrderModelAssembler orderAssembler) { + + this.repository = repository; + this.assembler = assembler; + this.orderAssembler = orderAssembler; + } + // end::constructor[] + + // Aggregate root + + @GetMapping("/employees") + CollectionModel> all() { + + List> employees = repository.findAll().stream() + .map(assembler::toModel) + .collect(Collectors.toList()); + + return new CollectionModel<>(employees, + linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); + } + + @PostMapping("/employees") + ResponseEntity newEmployee(@RequestBody Employee newEmployee) throws URISyntaxException { + + EntityModel entityModel = assembler.toModel(repository.save(newEmployee)); + + return ResponseEntity + .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) + .body(entityModel); + } + + // Single item + + @GetMapping("/employees/{id}") + EntityModel one(@PathVariable Long id) { + + Employee employee = repository.findById(id) + .orElseThrow(() -> new EmployeeNotFoundException(id)); + + return assembler.toModel(employee); + } + + @PutMapping("/employees/{id}") + ResponseEntity replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) throws URISyntaxException { + + Employee updatedEmployee = repository.findById(id) + .map(employee -> { + employee.setName(newEmployee.getName()); + employee.setRole(newEmployee.getRole()); + return repository.save(employee); + }) + .orElseGet(() -> { + newEmployee.setId(id); + return repository.save(newEmployee); + }); + + EntityModel entityModel = assembler.toModel(updatedEmployee); + + return ResponseEntity + .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) + .body(entityModel); + } + + @DeleteMapping("/employees/{id}") + ResponseEntity deleteEmployee(@PathVariable Long id) { + + repository.deleteById(id); + + return ResponseEntity.noContent().build(); + } +} diff --git a/tut-rest/links/src/main/java/payroll/EmployeeModelAssembler.java b/tut-rest/links/src/main/java/payroll/EmployeeModelAssembler.java new file mode 100644 index 0000000..fd79c56 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/EmployeeModelAssembler.java @@ -0,0 +1,18 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +@Component +class EmployeeModelAssembler implements RepresentationModelAssembler> { + + @Override + public EntityModel toModel(Employee employee) { + return new EntityModel<>(employee, + linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(), + linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); + } +} diff --git a/tut-rest/links/src/main/java/payroll/EmployeeNotFoundAdvice.java b/tut-rest/links/src/main/java/payroll/EmployeeNotFoundAdvice.java new file mode 100644 index 0000000..0e162f1 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/EmployeeNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class EmployeeNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(EmployeeNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String employeeNotFoundHandler(EmployeeNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/links/src/main/java/payroll/EmployeeNotFoundException.java b/tut-rest/links/src/main/java/payroll/EmployeeNotFoundException.java new file mode 100644 index 0000000..90ede89 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/EmployeeNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class EmployeeNotFoundException extends RuntimeException { + + EmployeeNotFoundException(Long id) { + super("Could not find employee " + id); + } +} diff --git a/tut-rest/links/src/main/java/payroll/EmployeeRepository.java b/tut-rest/links/src/main/java/payroll/EmployeeRepository.java new file mode 100644 index 0000000..dbeb9ec --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/EmployeeRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface EmployeeRepository extends JpaRepository { + +} diff --git a/tut-rest/links/src/main/java/payroll/LoadDatabase.java b/tut-rest/links/src/main/java/payroll/LoadDatabase.java new file mode 100644 index 0000000..b911ba0 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/LoadDatabase.java @@ -0,0 +1,34 @@ +package payroll; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +class LoadDatabase { + + @Bean + CommandLineRunner initDatabase(EmployeeRepository employeeRepository, + OrderRepository orderRepository) { + return args -> { + employeeRepository.save(new Employee("Bilbo", "Baggins", "burglar")); + employeeRepository.save(new Employee("Frodo", "Baggins", "thief")); + + employeeRepository.findAll().forEach(employee -> { + log.info("Preloaded " + employee); + }); + + // tag::order[] + orderRepository.save(new Order("MacBook Pro", Status.COMPLETED)); + orderRepository.save(new Order("iPhone", Status.IN_PROGRESS)); + + orderRepository.findAll().forEach(order -> { + log.info("Preloaded " + order); + }); + // end::order[] + }; + } +} diff --git a/tut-rest/links/src/main/java/payroll/Order.java b/tut-rest/links/src/main/java/payroll/Order.java new file mode 100644 index 0000000..ae0e1e8 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/Order.java @@ -0,0 +1,27 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; +import javax.persistence.Table; + +@Entity +@Data +@Table(name = "CUSTOMER_ORDER") +class Order { + + private @Id @GeneratedValue Long id; + + private String description; + private Status status; + + Order() {} + + Order(String description, Status status) { + + this.description = description; + this.status = status; + } +} diff --git a/tut-rest/links/src/main/java/payroll/OrderController.java b/tut-rest/links/src/main/java/payroll/OrderController.java new file mode 100644 index 0000000..0e8dc0b --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/OrderController.java @@ -0,0 +1,100 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.RepresentationModel; +import org.springframework.hateoas.mediatype.vnderrors.VndErrors; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +// tag::main[] +@RestController +class OrderController { + + private final OrderRepository orderRepository; + private final OrderModelAssembler assembler; + + OrderController(OrderRepository orderRepository, + OrderModelAssembler assembler) { + + this.orderRepository = orderRepository; + this.assembler = assembler; + } + + @GetMapping("/orders") + CollectionModel> all() { + + List> orders = orderRepository.findAll().stream() + .map(assembler::toModel) + .collect(Collectors.toList()); + + return new CollectionModel<>(orders, + linkTo(methodOn(OrderController.class).all()).withSelfRel()); + } + + @GetMapping("/orders/{id}") + EntityModel one(@PathVariable Long id) { + Order order = orderRepository.findById(id) + .orElseThrow(() -> new OrderNotFoundException(id)); + + return assembler.toModel(order); + } + + @PostMapping("/orders") + ResponseEntity> newOrder(@RequestBody Order order) { + + order.setStatus(Status.IN_PROGRESS); + Order newOrder = orderRepository.save(order); + + return ResponseEntity + .created(linkTo(methodOn(OrderController.class).one(newOrder.getId())).toUri()) + .body(assembler.toModel(newOrder)); + } + // end::main[] + + // tag::delete[] + @DeleteMapping("/orders/{id}/cancel") + ResponseEntity cancel(@PathVariable Long id) { + + Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id)); + + if (order.getStatus() == Status.IN_PROGRESS) { + order.setStatus(Status.CANCELLED); + return ResponseEntity.ok(assembler.toModel(orderRepository.save(order))); + } + + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(new VndErrors.VndError("Method not allowed", "You can't cancel an order that is in the " + order.getStatus() + " status")); + } + // end::delete[] + + // tag::complete[] + @PutMapping("/orders/{id}/complete") + ResponseEntity complete(@PathVariable Long id) { + + Order order = orderRepository.findById(id).orElseThrow(() -> new OrderNotFoundException(id)); + + if (order.getStatus() == Status.IN_PROGRESS) { + order.setStatus(Status.COMPLETED); + return ResponseEntity.ok(assembler.toModel(orderRepository.save(order))); + } + + return ResponseEntity + .status(HttpStatus.METHOD_NOT_ALLOWED) + .body(new VndErrors.VndError("Method not allowed", "You can't complete an order that is in the " + order.getStatus() + " status")); + } + // end::complete[] +} diff --git a/tut-rest/links/src/main/java/payroll/OrderModelAssembler.java b/tut-rest/links/src/main/java/payroll/OrderModelAssembler.java new file mode 100644 index 0000000..0c622b4 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/OrderModelAssembler.java @@ -0,0 +1,35 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.EntityModel; +import org.springframework.hateoas.server.RepresentationModelAssembler; +import org.springframework.stereotype.Component; + +@Component +class OrderModelAssembler implements RepresentationModelAssembler> { + + @Override + public EntityModel toModel(Order order) { + + // Unconditional links to single-item resource and aggregate root + + EntityModel orderModel = new EntityModel<>(order, + linkTo(methodOn(OrderController.class).one(order.getId())).withSelfRel(), + linkTo(methodOn(OrderController.class).all()).withRel("orders") + ); + + // Conditional links based on state of the order + + if (order.getStatus() == Status.IN_PROGRESS) { + orderModel.add( + linkTo(methodOn(OrderController.class) + .cancel(order.getId())).withRel("cancel")); + orderModel.add( + linkTo(methodOn(OrderController.class) + .complete(order.getId())).withRel("complete")); + } + + return orderModel; + } +} diff --git a/tut-rest/links/src/main/java/payroll/OrderNotFoundException.java b/tut-rest/links/src/main/java/payroll/OrderNotFoundException.java new file mode 100644 index 0000000..f45d9cc --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/OrderNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class OrderNotFoundException extends RuntimeException { + + OrderNotFoundException(Long id) { + super("Could not find order " + id); + } +} diff --git a/tut-rest/links/src/main/java/payroll/OrderRepository.java b/tut-rest/links/src/main/java/payroll/OrderRepository.java new file mode 100644 index 0000000..f54548c --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/OrderRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface OrderRepository extends JpaRepository { + +} diff --git a/tut-rest/links/src/main/java/payroll/PayrollApplication.java b/tut-rest/links/src/main/java/payroll/PayrollApplication.java new file mode 100644 index 0000000..3ef8fe6 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/PayrollApplication.java @@ -0,0 +1,12 @@ +package payroll; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PayrollApplication { + + public static void main(String... args) { + SpringApplication.run(PayrollApplication.class, args); + } +} diff --git a/tut-rest/links/src/main/java/payroll/RootController.java b/tut-rest/links/src/main/java/payroll/RootController.java new file mode 100644 index 0000000..11f98b3 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/RootController.java @@ -0,0 +1,20 @@ +package payroll; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; + +import org.springframework.hateoas.RepresentationModel; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class RootController { + + @GetMapping + RepresentationModel index() { + RepresentationModel rootModel = new RepresentationModel(); + rootModel.add(linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); + rootModel.add(linkTo(methodOn(OrderController.class).all()).withRel("orders")); + return rootModel; + } + +} diff --git a/tut-rest/links/src/main/java/payroll/Status.java b/tut-rest/links/src/main/java/payroll/Status.java new file mode 100644 index 0000000..97be4e6 --- /dev/null +++ b/tut-rest/links/src/main/java/payroll/Status.java @@ -0,0 +1,8 @@ +package payroll; + +enum Status { + + IN_PROGRESS, + COMPLETED, + CANCELLED; +} diff --git a/tut-rest/mvnw b/tut-rest/mvnw new file mode 100644 index 0000000..4e574d9 --- /dev/null +++ b/tut-rest/mvnw @@ -0,0 +1,227 @@ +#!/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 + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + 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 Mingw, 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 + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# 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"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +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_CONFIG "$@" diff --git a/tut-rest/mvnw.cmd b/tut-rest/mvnw.cmd new file mode 100644 index 0000000..e506408 --- /dev/null +++ b/tut-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 set title of command window +title %0 +@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 + +@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_CONFIG% %* +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/tut-rest/nonrest/pom.xml b/tut-rest/nonrest/pom.xml new file mode 100644 index 0000000..d3983d1 --- /dev/null +++ b/tut-rest/nonrest/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + + org.springframework.guides + tut-rest + 0.0.1-SNAPSHOT + + + nonrest + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/tut-rest/nonrest/src/main/java/payroll/Employee.java b/tut-rest/nonrest/src/main/java/payroll/Employee.java new file mode 100644 index 0000000..efef006 --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/Employee.java @@ -0,0 +1,23 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +class Employee { + + private @Id @GeneratedValue Long id; + private String name; + private String role; + + Employee() {} + + Employee(String name, String role) { + this.name = name; + this.role = role; + } +} diff --git a/tut-rest/nonrest/src/main/java/payroll/EmployeeController.java b/tut-rest/nonrest/src/main/java/payroll/EmployeeController.java new file mode 100644 index 0000000..6026e55 --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/EmployeeController.java @@ -0,0 +1,62 @@ +package payroll; + +import java.util.List; + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class EmployeeController { + + private final EmployeeRepository repository; + + EmployeeController(EmployeeRepository repository) { + this.repository = repository; + } + + // Aggregate root + + @GetMapping("/employees") + List all() { + return repository.findAll(); + } + + @PostMapping("/employees") + Employee newEmployee(@RequestBody Employee newEmployee) { + return repository.save(newEmployee); + } + + // Single item + + @GetMapping("/employees/{id}") + Employee one(@PathVariable Long id) { + + return repository.findById(id) + .orElseThrow(() -> new EmployeeNotFoundException(id)); + } + + @PutMapping("/employees/{id}") + Employee replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) { + + return repository.findById(id) + .map(employee -> { + employee.setName(newEmployee.getName()); + employee.setRole(newEmployee.getRole()); + return repository.save(employee); + }) + .orElseGet(() -> { + newEmployee.setId(id); + return repository.save(newEmployee); + }); + } + + @DeleteMapping("/employees/{id}") + void deleteEmployee(@PathVariable Long id) { + repository.deleteById(id); + } +} diff --git a/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java b/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java new file mode 100644 index 0000000..0e162f1 --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class EmployeeNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(EmployeeNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String employeeNotFoundHandler(EmployeeNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundException.java b/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundException.java new file mode 100644 index 0000000..90ede89 --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/EmployeeNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class EmployeeNotFoundException extends RuntimeException { + + EmployeeNotFoundException(Long id) { + super("Could not find employee " + id); + } +} diff --git a/tut-rest/nonrest/src/main/java/payroll/EmployeeRepository.java b/tut-rest/nonrest/src/main/java/payroll/EmployeeRepository.java new file mode 100644 index 0000000..dbeb9ec --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/EmployeeRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface EmployeeRepository extends JpaRepository { + +} diff --git a/tut-rest/nonrest/src/main/java/payroll/LoadDatabase.java b/tut-rest/nonrest/src/main/java/payroll/LoadDatabase.java new file mode 100644 index 0000000..8549edd --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/LoadDatabase.java @@ -0,0 +1,20 @@ +package payroll; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +class LoadDatabase { + + @Bean + CommandLineRunner initDatabase(EmployeeRepository repository) { + return args -> { + log.info("Preloading " + repository.save(new Employee("Bilbo Baggins", "burglar"))); + log.info("Preloading " + repository.save(new Employee("Frodo Baggins", "thief"))); + }; + } +} diff --git a/tut-rest/nonrest/src/main/java/payroll/PayrollApplication.java b/tut-rest/nonrest/src/main/java/payroll/PayrollApplication.java new file mode 100644 index 0000000..3ef8fe6 --- /dev/null +++ b/tut-rest/nonrest/src/main/java/payroll/PayrollApplication.java @@ -0,0 +1,12 @@ +package payroll; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PayrollApplication { + + public static void main(String... args) { + SpringApplication.run(PayrollApplication.class, args); + } +} diff --git a/tut-rest/pom.xml b/tut-rest/pom.xml new file mode 100644 index 0000000..34ecbe9 --- /dev/null +++ b/tut-rest/pom.xml @@ -0,0 +1,37 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.2.4.RELEASE + + + org.springframework.guides + tut-rest + 0.0.1-SNAPSHOT + pom + + + UTF-8 + 1.8 + + + + nonrest + rest + evolution + links + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + \ No newline at end of file diff --git a/tut-rest/rest/pom.xml b/tut-rest/rest/pom.xml new file mode 100644 index 0000000..d35b371 --- /dev/null +++ b/tut-rest/rest/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + + org.springframework.guides + tut-rest + 0.0.1-SNAPSHOT + + + rest + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-hateoas + + + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + true + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/tut-rest/rest/src/main/java/payroll/Character.java b/tut-rest/rest/src/main/java/payroll/Character.java new file mode 100644 index 0000000..e1cc066 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/Character.java @@ -0,0 +1,33 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +public class Character { + private @Id @GeneratedValue Long idChar; + private String id; + private String firstName; + private String lastName; + private String picture; + private Integer age; + private String idQuote; + private String quote; + + public Character() { + } + + public Character(String id, String firstName, String lastName, String picture, Integer age, String idQuote, String quote) { + this.id = id; + this.firstName = firstName; + this.lastName = lastName; + this.picture = picture; + this.age = age; + this.idQuote = idQuote; + this.quote = quote; + } +} diff --git a/tut-rest/rest/src/main/java/payroll/CharacterController.java b/tut-rest/rest/src/main/java/payroll/CharacterController.java new file mode 100644 index 0000000..339798f --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/CharacterController.java @@ -0,0 +1,86 @@ +package payroll; + + +import java.util.List; +import java.util.stream.Collectors; + +// tag::hateoas-imports[] +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +// end::hateoas-imports[] + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class CharacterController { + + private final CharacterRepository repository; + + CharacterController(CharacterRepository repository) { + this.repository = repository; + } + + // Aggregate root + + // tag::get-aggregate-root[] + @GetMapping("/characters") + CollectionModel> all() { + + List> characters = repository.findAll().stream() + .map(character -> new EntityModel<>(character, + linkTo(methodOn(CharacterController.class).one(character.getIdChar())).withSelfRel(), + linkTo(methodOn(CharacterController.class).all()).withRel("characters"))) + .collect(Collectors.toList()); + + return new CollectionModel<>(characters, + linkTo(methodOn(CharacterController.class).all()).withSelfRel()); + } + // end::get-aggregate-root[] + + @PostMapping("/characters") + Character newCharacter(@RequestBody Character newCharacter) { + return repository.save(newCharacter); + } + + // Single item + + // tag::get-single-item[] + @GetMapping("/characters/{idChar}") + EntityModel one(@PathVariable Long idChar) { + + Character character = repository.findById(idChar) + .orElseThrow(() -> new CharacterNotFoundException(idChar)); + + return new EntityModel<>(character, + linkTo(methodOn(CharacterController.class).one(idChar)).withSelfRel(), + linkTo(methodOn(CharacterController.class).all()).withRel("characters")); + } + // end::get-single-item[] + + @PutMapping("/characters/{idChar}") + Character replaceCharacter(@RequestBody Character newCharacter, @PathVariable Long idChar) { + + return repository.findById(idChar) + .map(character -> { + character.setFirstName(newCharacter.getFirstName()); + character.setLastName(newCharacter.getLastName()); + return repository.save(character); + }) + .orElseGet(() -> { + newCharacter.setIdChar(idChar); + return repository.save(newCharacter); + }); + } + + @DeleteMapping("/characters/{idChar}") + void deleteCharacter(@PathVariable Long idChar) { + repository.deleteById(idChar); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/CharacterNotFoundAdvice.java b/tut-rest/rest/src/main/java/payroll/CharacterNotFoundAdvice.java new file mode 100644 index 0000000..4c6b574 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/CharacterNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class CharacterNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(CharacterNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String employeeNotFoundHandler(CharacterNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/CharacterNotFoundException.java b/tut-rest/rest/src/main/java/payroll/CharacterNotFoundException.java new file mode 100644 index 0000000..5dda390 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/CharacterNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class CharacterNotFoundException extends RuntimeException { + + CharacterNotFoundException(Long id) { + super("Could not find character " + id); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/CharacterRepository.java b/tut-rest/rest/src/main/java/payroll/CharacterRepository.java new file mode 100644 index 0000000..8279449 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/CharacterRepository.java @@ -0,0 +1,6 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CharacterRepository extends JpaRepository { +} diff --git a/tut-rest/rest/src/main/java/payroll/Employee.java b/tut-rest/rest/src/main/java/payroll/Employee.java new file mode 100644 index 0000000..efef006 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/Employee.java @@ -0,0 +1,23 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +class Employee { + + private @Id @GeneratedValue Long id; + private String name; + private String role; + + Employee() {} + + Employee(String name, String role) { + this.name = name; + this.role = role; + } +} diff --git a/tut-rest/rest/src/main/java/payroll/EmployeeController.java b/tut-rest/rest/src/main/java/payroll/EmployeeController.java new file mode 100644 index 0000000..b76b3f9 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/EmployeeController.java @@ -0,0 +1,86 @@ +package payroll; + + +import java.util.List; +import java.util.stream.Collectors; + +// tag::hateoas-imports[] +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*; +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +// end::hateoas-imports[] + +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +class EmployeeController { + + private final EmployeeRepository repository; + + EmployeeController(EmployeeRepository repository) { + this.repository = repository; + } + + // Aggregate root + + // tag::get-aggregate-root[] + @GetMapping("/employees") + CollectionModel> all() { + + List> employees = repository.findAll().stream() + .map(employee -> new EntityModel<>(employee, + linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(), + linkTo(methodOn(EmployeeController.class).all()).withRel("employees"))) + .collect(Collectors.toList()); + + return new CollectionModel<>(employees, + linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); + } + // end::get-aggregate-root[] + + @PostMapping("/employees") + Employee newEmployee(@RequestBody Employee newEmployee) { + return repository.save(newEmployee); + } + + // Single item + + // tag::get-single-item[] + @GetMapping("/employees/{id}") + EntityModel one(@PathVariable Long id) { + + Employee employee = repository.findById(id) + .orElseThrow(() -> new EmployeeNotFoundException(id)); + + return new EntityModel<>(employee, + linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel(), + linkTo(methodOn(EmployeeController.class).all()).withRel("employees")); + } + // end::get-single-item[] + + @PutMapping("/employees/{id}") + Employee replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) { + + return repository.findById(id) + .map(employee -> { + employee.setName(newEmployee.getName()); + employee.setRole(newEmployee.getRole()); + return repository.save(employee); + }) + .orElseGet(() -> { + newEmployee.setId(id); + return repository.save(newEmployee); + }); + } + + @DeleteMapping("/employees/{id}") + void deleteEmployee(@PathVariable Long id) { + repository.deleteById(id); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundAdvice.java b/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundAdvice.java new file mode 100644 index 0000000..0e162f1 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class EmployeeNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(EmployeeNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String employeeNotFoundHandler(EmployeeNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundException.java b/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundException.java new file mode 100644 index 0000000..90ede89 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/EmployeeNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class EmployeeNotFoundException extends RuntimeException { + + EmployeeNotFoundException(Long id) { + super("Could not find employee " + id); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/EmployeeRepository.java b/tut-rest/rest/src/main/java/payroll/EmployeeRepository.java new file mode 100644 index 0000000..dbeb9ec --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/EmployeeRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface EmployeeRepository extends JpaRepository { + +} diff --git a/tut-rest/rest/src/main/java/payroll/LoadDatabase.java b/tut-rest/rest/src/main/java/payroll/LoadDatabase.java new file mode 100644 index 0000000..3e188aa --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/LoadDatabase.java @@ -0,0 +1,184 @@ +package payroll; + +import lombok.extern.slf4j.Slf4j; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +class LoadDatabase { + + @Bean + CommandLineRunner initDatabase(EmployeeRepository repository, CharacterRepository characterRepository, + QuoteRepository quoteRepository) { + return args -> { + + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff64d9be8f7aa11e0c44", + "59edee68706374dfa957842f", + "Homer", + "Simpson", + "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + 43, + "Wait a minute. Bart’s teacher is named ‘Krabappel’? Oh, I’ve been calling her ‘Crandall.’ Why didn’t anyone tell me? Ohhh, I’ve been making an idiot out of myself!" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6492d619b4a933a56b", + "59edee68706374dfa957842f", + "Homer", + "Simpson", + "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + 43, + "Now we play the waiting game…Ahh, the waiting game sucks. Let’s play Hungry Hungry Hippos!" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6477ac8539e526682b", + "59edee689509e51682ff8e02", + "Marge", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + 40, + "Go out on a Tuesday? Who am I, Charlie Sheen?" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6477ac8539e526682b", + "59edee689509e51682ff8e02", + "Marge", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + 40, + "Go out on a Tuesday? Who am I, Charlie Sheen?" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff647cf388e225bc5f4e", + "59edee689509e51682ff8e02", + "Marge", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/8/87/Marge_Simpson_2.png/revision/latest?cb=20150131104556", + 40, + "I brought you a tuna sandwich. They say it's brain food. I guess because there's so much dolphin in it, and you know how smart they are." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff64ac4dccc4b0e35784", + "59edee68eff3f80413c136f8", + "Lisa", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/1/12/Lisa_Simpson-0.png/revision/latest?cb=20161027220133", + 10, + "I’d be mortified if someone ever made a lousy product with the Simpson name on it." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6419f9d6df24d593fe", + "59edee68eff3f80413c136f8", + "Lisa", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/1/12/Lisa_Simpson-0.png/revision/latest?cb=20161027220133", + 10, + "You monster! You monster!" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6403042a54f6038044", + "59edee683406c7834ee7cdd8", + "Bart", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/a/ab/BartSimpson.jpg/revision/latest?cb=20141101133153", + 12, + "There’s only one thing to do at a moment like this: strut!" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff643d4737e47c71835c", + "59edee683406c7834ee7cdd8", + "Bart", + "Simpson", + "https://vignette.wikia.nocookie.net/simpsons/images/a/ab/BartSimpson.jpg/revision/latest?cb=20141101133153", + 12, + "Aren’t we forgetting the true meaning of Christmas: the birth of Santa." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff641b4c1f62aebe1e4d", + "59edee68b4b101bef064bf11", + "Hugo", + "Simpson", + "https://static.simpsonswiki.com/images/thumb/1/1a/Hugo_Simpson.png/200px-Hugo_Simpson.png", + 12, + "I made a Pigeon-rat." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff643fbeca90867aa34d", + "59edee68b4b101bef064bf11", + "Hugo", + "Simpson", + "https://static.simpsonswiki.com/images/thumb/1/1a/Hugo_Simpson.png/200px-Hugo_Simpson.png", + 12, + "Am I? Well, perhaps we're all a little crazy. I know I am. " + + "I went mad after they tore us apart, but I'll be sane… once I sew us back together." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6494f9aef192ef4813", + "59edee68874eb2fa23678344", + "Ned", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/8/84/Ned_Flanders.png/revision/latest?cb=20100513160156", + 50, + "Diddly" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff64e6597a07e8e5dd33", + "59edee68874eb2fa23678344", + "Ned", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/8/84/Ned_Flanders.png/revision/latest?cb=20100513160156", + 50, + "I give you the jury of the damned. Benedict Arnold, Lizzie Borden, Richard Nixon..." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff6458b6a68631660120", + "59edee68efd448eefb265420", + "Rod", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/e/e6/Rod_Flanders2.png/revision/latest?cb=20140817110904", + 13, + "We just move one space at a time. It's less fun that way." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff646c740f1218a30a06", + "59edee68efd448eefb265420", + "Rod", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/e/e6/Rod_Flanders2.png/revision/latest?cb=20140817110904", + 13, + "Thank you, door!" + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff644abadfc108cac2a6", + "59edee68ea392a3947485d41", + "Todd", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/1/18/Todd_Flanders.png/revision/latest?cb=20131223200228", + 12, + "Daddy says dice are wicked." + ))); + log.info("Preloading" + quoteRepository.save(new Quote( + "59edff64fd7790417d74b3a1", + "59edee682c7acf7bfac7e66b", + "Maude", + "Flanders", + "https://vignette.wikia.nocookie.net/simpsons/images/9/95/Maude_Flanders.png/revision/latest/scale-to-width-down/286?cb=20170923223722", + 49, + "Oh, I don't care for the speed, but I can't get enough of that safety gear - helmets, roll bars, caution flags..." + ))); + log.info("Preloading " + repository.save(new Employee("Bilbo Baggins", "burglar"))); + log.info("Preloading " + repository.save(new Employee("Frodo Baggins", "thief"))); + log.info("Preloading " + characterRepository.save(new Character( + "59edee68706374dfa957842f", + "Homer", + "Simpson", + "http://www.trbimg.com/img-573a089a/turbine/ct-homer-simpson-live-pizza-debate-met-0517-20160516", + 43, + "59edff6492d619b4a933a56b", + "Now we play the waiting game…Ahh, the waiting game sucks. Let’s play Hungry Hungry Hippos!"))); + }; + } +} diff --git a/tut-rest/rest/src/main/java/payroll/PayrollApplication.java b/tut-rest/rest/src/main/java/payroll/PayrollApplication.java new file mode 100644 index 0000000..d2f6355 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/PayrollApplication.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +import java.util.Collections; + +@SpringBootApplication +public class PayrollApplication { + + public static void main(String... args) { + //SpringApplication.run(PayrollApplication.class, args); + SpringApplication app = new SpringApplication(PayrollApplication.class); + app.setDefaultProperties(Collections + .singletonMap("server.port", "8083")); + app.run(args); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/Quote.java b/tut-rest/rest/src/main/java/payroll/Quote.java new file mode 100644 index 0000000..00ce103 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/Quote.java @@ -0,0 +1,34 @@ +package payroll; + +import lombok.Data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.Id; + +@Data +@Entity +public class Quote { + + private @Id @GeneratedValue Long idQuote; + private String id; + private String character; + private String firstName; + private String lastName; + private String picture; + private Integer age; + private String phrase; + + public Quote() { + } + + public Quote(String id, String character, String firstName, String lastName, String picture, Integer age, String phrase) { + this.id = id; + this.character = character; + this.firstName = firstName; + this.lastName = lastName; + this.picture = picture; + this.age = age; + this.phrase = phrase; + } +} diff --git a/tut-rest/rest/src/main/java/payroll/QuoteController.java b/tut-rest/rest/src/main/java/payroll/QuoteController.java new file mode 100644 index 0000000..48eeae1 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/QuoteController.java @@ -0,0 +1,83 @@ +package payroll; + + +import org.springframework.hateoas.CollectionModel; +import org.springframework.hateoas.EntityModel; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo; +import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn; + +// tag::hateoas-imports[] +// end::hateoas-imports[] + +@RestController +class QuoteController { + + private final QuoteRepository repository; + + QuoteController(QuoteRepository repository) { + this.repository = repository; + } + + // Aggregate root + + // tag::get-aggregate-root[] + @GetMapping("/quotes") + CollectionModel> all() { + + List> quotes = repository.findAll().stream() + .map(quote -> new EntityModel<>(quote, + linkTo(methodOn(QuoteController.class).one(quote.getIdQuote())).withSelfRel(), + linkTo(methodOn(QuoteController.class).all()).withRel("quotes"))) + .collect(Collectors.toList()); + + return new CollectionModel<>(quotes, + linkTo(methodOn(QuoteController.class).all()).withSelfRel()); + } + // end::get-aggregate-root[] + + @PostMapping("/quotes") + Quote newQuote(@RequestBody Quote newQuote) { + return repository.save(newQuote); + } + + // Single item + + // tag::get-single-item[] + @GetMapping("/quotes/{idQuote}") + EntityModel one(@PathVariable Long idQuote) { + + Quote quote = repository.findById(idQuote) + .orElseThrow(() -> new QuoteNotFoundException(idQuote)); + + return new EntityModel<>(quote, + linkTo(methodOn(QuoteController.class).one(idQuote)).withSelfRel(), + linkTo(methodOn(QuoteController.class).all()).withRel("quotes")); + } + // end::get-single-item[] + + @PutMapping("/quotes/{idQuote}") + Quote replaceQuote(@RequestBody Quote newQuote, @PathVariable Long idQuote) { + + return repository.findById(idQuote) + .map(quote -> { + quote.setFirstName(newQuote.getFirstName()); + quote.setLastName(newQuote.getLastName()); + quote.setPhrase(newQuote.getPhrase()); + return repository.save(quote); + }) + .orElseGet(() -> { + newQuote.setIdQuote(idQuote); + return repository.save(newQuote); + }); + } + + @DeleteMapping("/quotes/{idQuote}") + void deleteQuote(@PathVariable Long idQuote) { + repository.deleteById(idQuote); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/QuoteNotFoundAdvice.java b/tut-rest/rest/src/main/java/payroll/QuoteNotFoundAdvice.java new file mode 100644 index 0000000..4c0cbf6 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/QuoteNotFoundAdvice.java @@ -0,0 +1,18 @@ +package payroll; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ControllerAdvice +class QuoteNotFoundAdvice { + + @ResponseBody + @ExceptionHandler(QuoteNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + String quoteNotFoundHandler(QuoteNotFoundException ex) { + return ex.getMessage(); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/QuoteNotFoundException.java b/tut-rest/rest/src/main/java/payroll/QuoteNotFoundException.java new file mode 100644 index 0000000..83b7a14 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/QuoteNotFoundException.java @@ -0,0 +1,8 @@ +package payroll; + +class QuoteNotFoundException extends RuntimeException { + + QuoteNotFoundException(Long id) { + super("Could not find quote " + id); + } +} diff --git a/tut-rest/rest/src/main/java/payroll/QuoteRepository.java b/tut-rest/rest/src/main/java/payroll/QuoteRepository.java new file mode 100644 index 0000000..d680762 --- /dev/null +++ b/tut-rest/rest/src/main/java/payroll/QuoteRepository.java @@ -0,0 +1,7 @@ +package payroll; + +import org.springframework.data.jpa.repository.JpaRepository; + +interface QuoteRepository extends JpaRepository { + +}