From f1c11281dc67e5c61a5d0c66cfdb6d6aca9c1eba Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Fri, 21 Jul 2017 09:26:37 -0600 Subject: [PATCH 01/22] Initial commit --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..4fa332f5c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +build +.gradle +.idea +*.iml +ci/variables.yml From 805bb796f93e8df1b98281c75873b7fb799eb31f Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Tue, 7 Nov 2017 14:15:12 -0700 Subject: [PATCH 02/22] Simple Spring Boot app --- gradlew | 172 ++++++++++++++++++ gradlew.bat | 84 +++++++++ .../io/pivotal/pal/PalTrackerApplication.java | 12 ++ .../pal/tracker/WelcomeController.java | 13 ++ 4 files changed, 281 insertions(+) create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 src/main/java/io/pivotal/pal/PalTrackerApplication.java create mode 100644 src/main/java/io/pivotal/pal/tracker/WelcomeController.java diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..cccdd3d51 --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## 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="" + +# 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, switch paths to Windows format before running java +if $cygwin ; 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/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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= + +@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/src/main/java/io/pivotal/pal/PalTrackerApplication.java b/src/main/java/io/pivotal/pal/PalTrackerApplication.java new file mode 100644 index 000000000..80f2a72a5 --- /dev/null +++ b/src/main/java/io/pivotal/pal/PalTrackerApplication.java @@ -0,0 +1,12 @@ +package io.pivotal.pal.tracker; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PalTrackerApplication { + + public static void main(String[] args) { + SpringApplication.run(PalTrackerApplication.class, args); + } +} \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/WelcomeController.java b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java new file mode 100644 index 000000000..2d477ae71 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java @@ -0,0 +1,13 @@ +package io.pivotal.pal.tracker; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class WelcomeController { + + @GetMapping("/") + public String sayHello() { + return "hello"; + } +} \ No newline at end of file From ec52a4873fc27a358ae944df791ec576dbe9ee3b Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Thu, 20 Jul 2017 13:56:50 -0600 Subject: [PATCH 03/22] Add tests for deployment lab --- .../pal/tracker/EnvControllerTest.java | 28 +++++++++++++++++++ .../pal/tracker/WelcomeControllerTest.java | 16 +++++++++++ .../pal/trackerapi/WelcomeApiTest.java | 26 +++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/test/java/test/pivotal/pal/tracker/EnvControllerTest.java create mode 100644 src/test/java/test/pivotal/pal/tracker/WelcomeControllerTest.java create mode 100644 src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java diff --git a/src/test/java/test/pivotal/pal/tracker/EnvControllerTest.java b/src/test/java/test/pivotal/pal/tracker/EnvControllerTest.java new file mode 100644 index 000000000..fda0f0f34 --- /dev/null +++ b/src/test/java/test/pivotal/pal/tracker/EnvControllerTest.java @@ -0,0 +1,28 @@ +package test.pivotal.pal.tracker; + +import org.junit.Test; + +import java.util.Map; +import io.pivotal.pal.tracker.EnvController; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EnvControllerTest { + @Test + public void getEnv() throws Exception { + EnvController controller = new EnvController( + "8675", + "12G", + "34", + "123.sesame.street" + ); + + Map env = controller.getEnv(); + + assertThat(env.get("PORT")).isEqualTo("8675"); + assertThat(env.get("MEMORY_LIMIT")).isEqualTo("12G"); + assertThat(env.get("CF_INSTANCE_INDEX")).isEqualTo("34"); + assertThat(env.get("CF_INSTANCE_ADDR")).isEqualTo("123.sesame.street"); + } + +} diff --git a/src/test/java/test/pivotal/pal/tracker/WelcomeControllerTest.java b/src/test/java/test/pivotal/pal/tracker/WelcomeControllerTest.java new file mode 100644 index 000000000..bfa8271a0 --- /dev/null +++ b/src/test/java/test/pivotal/pal/tracker/WelcomeControllerTest.java @@ -0,0 +1,16 @@ +package test.pivotal.pal.tracker; + +import io.pivotal.pal.tracker.WelcomeController; +import org.junit.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +public class WelcomeControllerTest { + + @Test + public void itSaysHello() throws Exception { + WelcomeController controller = new WelcomeController("A welcome message"); + + assertThat(controller.sayHello()).isEqualTo("A welcome message"); + } +} diff --git a/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java new file mode 100644 index 000000000..cc7091ed4 --- /dev/null +++ b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java @@ -0,0 +1,26 @@ +package test.pivotal.pal.trackerapi; + +import io.pivotal.pal.tracker.PalTrackerApplication; +import org.junit.Test; +import org.junit.runner.RunWith; +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.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) +public class WelcomeApiTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void exampleTest() { + String body = this.restTemplate.getForObject("/", String.class); + assertThat(body).isEqualTo("Hello from test"); + } +} From 2bd057d8e002dde8d7ba5cad4fed43a994f0d271 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Tue, 7 Nov 2017 21:32:51 -0700 Subject: [PATCH 04/22] Concourse Lab Updates --- ci/build.yml | 23 ++++++++++ ci/pipeline.yml | 31 +++++++++++++ ci/variables.example.yml | 9 ++++ manifest.yml | 7 +++ .../io/pivotal/pal/tracker/EnvController.java | 44 +++++++++++++++++++ .../pal/tracker/WelcomeController.java | 10 ++++- 6 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 ci/build.yml create mode 100644 ci/pipeline.yml create mode 100644 ci/variables.example.yml create mode 100644 manifest.yml create mode 100644 src/main/java/io/pivotal/pal/tracker/EnvController.java diff --git a/ci/build.yml b/ci/build.yml new file mode 100644 index 000000000..d6378d09a --- /dev/null +++ b/ci/build.yml @@ -0,0 +1,23 @@ +platform: linux + +image_resource: + type: docker-image + source: + repository: openjdk + tag: '8-jdk' + +inputs: + - name: pal-tracker + +outputs: + - name: build-output + +run: + path: bash + args: + - -exc + - | + cd pal-tracker + chmod +x gradlew + ./gradlew build + cp build/libs/pal-tracker.jar ../build-output diff --git a/ci/pipeline.yml b/ci/pipeline.yml new file mode 100644 index 000000000..c9d05b1ef --- /dev/null +++ b/ci/pipeline.yml @@ -0,0 +1,31 @@ +--- +resources: +- name: pal-tracker + type: git + source: + uri: {{github-repository}} + branch: master + private_key: {{github-private-key}} + +- name: deploy + type: cf + source: + api: {{cf-api-url}} + username: {{cf-username}} + password: {{cf-password}} + organization: {{cf-org}} + space: sandbox + +jobs: +- name: build-and-deploy + plan: + - get: pal-tracker + trigger: true + - task: build and test + file: pal-tracker/ci/build.yml + - put: deploy + params: + manifest: pal-tracker/manifest.yml + path: build-output/pal-tracker.jar + environment_variables: + WELCOME_MESSAGE: "Hello from Concourse" diff --git a/ci/variables.example.yml b/ci/variables.example.yml new file mode 100644 index 000000000..5a440bd47 --- /dev/null +++ b/ci/variables.example.yml @@ -0,0 +1,9 @@ +cf-api-url: CF_API_URL +cf-username: CF_USERNAME +cf-password: CF_PASSWORD +cf-org: CF_ORG +github-repository: git@github.com:GITHUB_USERNAME/pal-tracker.git +github-private-key: | + -----BEGIN RSA PRIVATE KEY----- + REPLACE WITH YOUR PRIVATE KEY HERE + -----END RSA PRIVATE KEY----- diff --git a/manifest.yml b/manifest.yml new file mode 100644 index 000000000..41693d446 --- /dev/null +++ b/manifest.yml @@ -0,0 +1,7 @@ +--- +applications: +- name: pal-tracker + path: build/libs/pal-tracker.jar + random-route: true +# env: +# WELCOME_MESSAGE: Hello from Cloud Foundry \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/EnvController.java b/src/main/java/io/pivotal/pal/tracker/EnvController.java new file mode 100644 index 000000000..356fd231e --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/EnvController.java @@ -0,0 +1,44 @@ +package io.pivotal.pal.tracker; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.beans.factory.annotation.Value; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class EnvController { + + + private String port; + + private String memoryLimit; + + private String cfInstanceIndex; + + private String cfInstanceAddr; + + public EnvController(@Value("${PORT:NOT SET}") String port, + @Value("${MEMORY_LIMIT:NOT SET}") String memoryLimit, + @Value("${CF_INSTANCE_INDEX:NOT SET}") String cfInstanceIndex, + @Value("${CF_INSTANCE_ADDR:NOT SET}") String cfInstanceAddress) + { + this.port = port; + this.memoryLimit = memoryLimit; + this.cfInstanceIndex = cfInstanceIndex; + this.cfInstanceAddr = cfInstanceAddress; + } + + @GetMapping("/env") + public Map getEnv() { + Map env = new HashMap<>(); + env.put("PORT", this.port); + env.put("MEMORY_LIMIT", memoryLimit); + env.put("CF_INSTANCE_INDEX", cfInstanceIndex); + env.put("CF_INSTANCE_ADDR", cfInstanceAddr); + + return env; + + } +} \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/WelcomeController.java b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java index 2d477ae71..b5b8fd9a8 100644 --- a/src/main/java/io/pivotal/pal/tracker/WelcomeController.java +++ b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java @@ -2,12 +2,20 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.beans.factory.annotation.Value; @RestController public class WelcomeController { + + private String message; + + public WelcomeController(@Value("${WELCOME_MESSAGE}") String message) { + this.message = message; + } + @GetMapping("/") public String sayHello() { - return "hello"; + return this.message; } } \ No newline at end of file From 895d56cbabf7d6511329eb384553d0dbd7c0eb07 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 08:23:26 -0700 Subject: [PATCH 05/22] Concourse Lab Updates --- gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54727 bytes gradle/wrapper/gradle-wrapper.properties | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..27768f1bbac3ce2d055b20d521f12da78d331e8e GIT binary patch literal 54727 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

i| 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..92165eede --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.3-bin.zip From 64b9a7373c7ab9c340221847a8fa0c537d38d8b9 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 08:26:59 -0700 Subject: [PATCH 06/22] Concourse Lab Updates --- build.gradle | 21 +++++++++++++++++++++ settings.gradle | 1 + 2 files changed, 22 insertions(+) create mode 100644 build.gradle create mode 100644 settings.gradle diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..e4d95d306 --- /dev/null +++ b/build.gradle @@ -0,0 +1,21 @@ +plugins { + id "java" + id "org.springframework.boot" version "1.5.8.RELEASE" +} + +repositories { + mavenCentral() +} + +dependencies { + compile("org.springframework.boot:spring-boot-starter-web") + testCompile("org.springframework.boot:spring-boot-starter-test") +} + +bootRun.environment([ + "WELCOME_MESSAGE": "hello", +]) + +test.environment([ + "WELCOME_MESSAGE": "Hello from test", +]) diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..ef961960e --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = "pal-tracker" \ No newline at end of file From 9767abb747f35cbf3c449dee9bf651e529b0f81a Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 09:28:57 -0700 Subject: [PATCH 07/22] Concourse Lab 2 Updates --- ci/build.yml | 5 +-- ci/pipeline.yml | 66 +++++++++++++++++++++++++++++++++++----- ci/variables.example.yml | 3 ++ manifest.yml | 7 ----- 4 files changed, 65 insertions(+), 16 deletions(-) delete mode 100644 manifest.yml diff --git a/ci/build.yml b/ci/build.yml index d6378d09a..e2bb9473a 100644 --- a/ci/build.yml +++ b/ci/build.yml @@ -8,6 +8,7 @@ image_resource: inputs: - name: pal-tracker + - name: version outputs: - name: build-output @@ -19,5 +20,5 @@ run: - | cd pal-tracker chmod +x gradlew - ./gradlew build - cp build/libs/pal-tracker.jar ../build-output + ./gradlew -P version=$(cat ../version/number) build + cp build/libs/pal-tracker-*.jar ../build-output \ No newline at end of file diff --git a/ci/pipeline.yml b/ci/pipeline.yml index c9d05b1ef..c34c3b2a9 100644 --- a/ci/pipeline.yml +++ b/ci/pipeline.yml @@ -7,25 +7,77 @@ resources: branch: master private_key: {{github-private-key}} -- name: deploy +- name: pal-tracker-artifacts + type: s3 + source: + bucket: {{aws-bucket}} + regexp: releases/pal-tracker-(.*).jar + access_key_id: {{aws-access-key-id}} + secret_access_key: {{aws-secret-access-key}} + +- name: version + type: semver + source: + bucket: {{aws-bucket}} + key: pal-tracker/version + access_key_id: {{aws-access-key-id}} + secret_access_key: {{aws-secret-access-key}} + +- name: review-deployment + type: cf + source: + api: {{cf-api-url}} + username: {{cf-username}} + password: {{cf-password}} + organization: {{cf-org}} + space: review + +- name: production-deployment type: cf source: api: {{cf-api-url}} username: {{cf-username}} password: {{cf-password}} organization: {{cf-org}} - space: sandbox + space: production jobs: -- name: build-and-deploy +- name: build plan: - get: pal-tracker trigger: true + - get: version + params: {bump: patch} - task: build and test file: pal-tracker/ci/build.yml - - put: deploy + - put: pal-tracker-artifacts + params: + file: build-output/pal-tracker-*.jar + - put: version + params: + file: version/number + +- name: deploy-review + plan: + - get: pal-tracker + - get: pal-tracker-artifacts + trigger: true + passed: [build] + - put: review-deployment + params: + manifest: pal-tracker/manifest-review.yml + path: pal-tracker-artifacts/pal-tracker-*.jar + environment_variables: + WELCOME_MESSAGE: "Hello from the review environment" + +- name: deploy-production + plan: + - get: pal-tracker + - get: pal-tracker-artifacts + passed: [deploy-review] + - put: production-deployment params: - manifest: pal-tracker/manifest.yml - path: build-output/pal-tracker.jar + manifest: pal-tracker/manifest-production.yml + path: pal-tracker-artifacts/pal-tracker-*.jar environment_variables: - WELCOME_MESSAGE: "Hello from Concourse" + WELCOME_MESSAGE: "Hello from the production environment" \ No newline at end of file diff --git a/ci/variables.example.yml b/ci/variables.example.yml index 5a440bd47..c72926f28 100644 --- a/ci/variables.example.yml +++ b/ci/variables.example.yml @@ -7,3 +7,6 @@ github-private-key: | -----BEGIN RSA PRIVATE KEY----- REPLACE WITH YOUR PRIVATE KEY HERE -----END RSA PRIVATE KEY----- +aws-access-key-id: +aws-secret-access-key: +aws-bucket: \ No newline at end of file diff --git a/manifest.yml b/manifest.yml deleted file mode 100644 index 41693d446..000000000 --- a/manifest.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -applications: -- name: pal-tracker - path: build/libs/pal-tracker.jar - random-route: true -# env: -# WELCOME_MESSAGE: Hello from Cloud Foundry \ No newline at end of file From 7b60201a98287ee1c0a3da1deeca7ad08ba6c20c Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 09:35:55 -0700 Subject: [PATCH 08/22] Concourse Lab 2 Updates --- manifest-production.yml | 7 +++++++ manifest-review.yml | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 manifest-production.yml create mode 100644 manifest-review.yml diff --git a/manifest-production.yml b/manifest-production.yml new file mode 100644 index 000000000..79b9be318 --- /dev/null +++ b/manifest-production.yml @@ -0,0 +1,7 @@ +--- +applications: +- name: pal-tracker + path: build/libs/pal-tracker.jar + host: ps-pal-tracker +# env: +# WELCOME_MESSAGE: Hello from Cloud Foundry diff --git a/manifest-review.yml b/manifest-review.yml new file mode 100644 index 000000000..9c77e9c55 --- /dev/null +++ b/manifest-review.yml @@ -0,0 +1,7 @@ +--- +applications: +- name: pal-tracker + path: build/libs/pal-tracker.jar + host: ps-pal-tracker-review +# env: +# WELCOME_MESSAGE: Hello from Cloud Foundry From caa9ef1e45865a1140bf89dd58b5617d2d5d638c Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 09:38:42 -0700 Subject: [PATCH 09/22] Concourse Lab 2 Updates --- manifest-production.yml | 2 +- manifest-review.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/manifest-production.yml b/manifest-production.yml index 79b9be318..7a64d1606 100644 --- a/manifest-production.yml +++ b/manifest-production.yml @@ -2,6 +2,6 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker + host: mjs-pal-tracker # env: # WELCOME_MESSAGE: Hello from Cloud Foundry diff --git a/manifest-review.yml b/manifest-review.yml index 9c77e9c55..175eab850 100644 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -2,6 +2,6 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker-review + host: mjs-pal-tracker-review # env: # WELCOME_MESSAGE: Hello from Cloud Foundry From ad538c8becacb8e18f62bb8216930dbb4d8c7199 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Thu, 20 Jul 2017 15:04:20 -0600 Subject: [PATCH 10/22] Add tests for MVC lab --- .../InMemoryTimeEntryRepositoryTest.java | 71 ++++++++++ .../pal/tracker/TimeEntryControllerTest.java | 106 +++++++++++++++ .../pal/trackerapi/TimeEntryApiTest.java | 126 ++++++++++++++++++ 3 files changed, 303 insertions(+) create mode 100644 src/test/java/test/pivotal/pal/tracker/InMemoryTimeEntryRepositoryTest.java create mode 100644 src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java create mode 100644 src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java diff --git a/src/test/java/test/pivotal/pal/tracker/InMemoryTimeEntryRepositoryTest.java b/src/test/java/test/pivotal/pal/tracker/InMemoryTimeEntryRepositoryTest.java new file mode 100644 index 000000000..c88bb8266 --- /dev/null +++ b/src/test/java/test/pivotal/pal/tracker/InMemoryTimeEntryRepositoryTest.java @@ -0,0 +1,71 @@ +package test.pivotal.pal.tracker; + +import io.pivotal.pal.tracker.InMemoryTimeEntryRepository; +import io.pivotal.pal.tracker.TimeEntry; +import org.junit.Test; + +import java.time.LocalDate; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; + +public class InMemoryTimeEntryRepositoryTest { + @Test + public void create() throws Exception { + InMemoryTimeEntryRepository repo = new InMemoryTimeEntryRepository(); + TimeEntry createdTimeEntry = repo.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + + TimeEntry expected = new TimeEntry(1L, 123, 456, LocalDate.parse("2017-01-08"), 8); + assertThat(createdTimeEntry).isEqualTo(expected); + + TimeEntry readEntry = repo.find(createdTimeEntry.getId()); + assertThat(readEntry).isEqualTo(expected); + } + + @Test + public void find() throws Exception { + InMemoryTimeEntryRepository repo = new InMemoryTimeEntryRepository(); + repo.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + + TimeEntry expected = new TimeEntry(1L, 123, 456, LocalDate.parse("2017-01-08"), 8); + TimeEntry readEntry = repo.find(1L); + assertThat(readEntry).isEqualTo(expected); + } + + @Test + public void list() throws Exception { + InMemoryTimeEntryRepository repo = new InMemoryTimeEntryRepository(); + repo.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + repo.create(new TimeEntry(789, 654, LocalDate.parse("2017-01-07"), 4)); + + List expected = asList( + new TimeEntry(1L, 123, 456, LocalDate.parse("2017-01-08"), 8), + new TimeEntry(2L, 789, 654, LocalDate.parse("2017-01-07"), 4) + ); + assertThat(repo.list()).isEqualTo(expected); + } + + @Test + public void update() throws Exception { + InMemoryTimeEntryRepository repo = new InMemoryTimeEntryRepository(); + TimeEntry created = repo.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + + TimeEntry updatedEntry = repo.update( + created.getId(), + new TimeEntry(321, 654, LocalDate.parse("2017-01-09"), 5)); + + TimeEntry expected = new TimeEntry(created.getId(), 321, 654, LocalDate.parse("2017-01-09"), 5); + assertThat(updatedEntry).isEqualTo(expected); + assertThat(repo.find(created.getId())).isEqualTo(expected); + } + + @Test + public void delete() throws Exception { + InMemoryTimeEntryRepository repo = new InMemoryTimeEntryRepository(); + TimeEntry created = repo.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + + repo.delete(created.getId()); + assertThat(repo.list()).isEmpty(); + } +} diff --git a/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java b/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java new file mode 100644 index 000000000..f7c0090e3 --- /dev/null +++ b/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java @@ -0,0 +1,106 @@ +package test.pivotal.pal.tracker; + +import io.pivotal.pal.tracker.TimeEntry; +import io.pivotal.pal.tracker.TimeEntryController; +import io.pivotal.pal.tracker.TimeEntryRepository; +import org.junit.Before; +import org.junit.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.time.LocalDate; +import java.util.List; + +import static java.util.Arrays.asList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.*; + +public class TimeEntryControllerTest { + private TimeEntryRepository timeEntryRepository; + private TimeEntryController controller; + + @Before + public void setUp() throws Exception { + timeEntryRepository = mock(TimeEntryRepository.class); + controller = new TimeEntryController(timeEntryRepository); + } + + @Test + public void testCreate() throws Exception { + TimeEntry expected = new TimeEntry(1L, 123, 456, LocalDate.parse("2017-01-08"), 8); + doReturn(expected) + .when(timeEntryRepository) + .create(any(TimeEntry.class)); + + ResponseEntity response = controller.create(new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8)); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertThat(response.getBody()).isEqualTo(expected); + } + + @Test + public void testRead() throws Exception { + TimeEntry expected = new TimeEntry(1L, 123, 456, LocalDate.parse("2017-01-08"), 8); + doReturn(expected) + .when(timeEntryRepository) + .find(1L); + + ResponseEntity response = controller.read(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expected); + } + + @Test + public void testRead_NotFound() throws Exception { + doReturn(null) + .when(timeEntryRepository) + .find(1L); + + ResponseEntity response = controller.read(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testList() throws Exception { + List expected = asList( + new TimeEntry(1, 123, 456, LocalDate.parse("2017-01-08"), 8), + new TimeEntry(2, 789, 321, LocalDate.parse("2017-01-07"), 4) + ); + doReturn(expected).when(timeEntryRepository).list(); + + ResponseEntity> response = controller.list(); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expected); + } + + @Test + public void testUpdate() throws Exception { + TimeEntry expected = new TimeEntry(1, 987, 654, LocalDate.parse("2017-01-07"), 4); + doReturn(expected) + .when(timeEntryRepository) + .update(eq(1L), any(TimeEntry.class)); + + ResponseEntity response = controller.update(1L, expected); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isEqualTo(expected); + } + + @Test + public void testUpdate_NotFound() throws Exception { + doReturn(null) + .when(timeEntryRepository) + .update(eq(1L), any(TimeEntry.class)); + + ResponseEntity response = controller.update(1L, new TimeEntry()); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + public void testDelete() throws Exception { + ResponseEntity response = controller.delete(1L); + verify(timeEntryRepository).delete(1L); + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + } +} diff --git a/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java new file mode 100644 index 000000000..b742e5447 --- /dev/null +++ b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java @@ -0,0 +1,126 @@ +package test.pivotal.pal.trackerapi; + +import com.jayway.jsonpath.DocumentContext; +import io.pivotal.pal.tracker.PalTrackerApplication; +import io.pivotal.pal.tracker.TimeEntry; +import org.junit.Test; +import org.junit.runner.RunWith; +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.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit4.SpringRunner; + +import java.time.LocalDate; +import java.util.Collection; + +import static com.jayway.jsonpath.JsonPath.parse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) +public class TimeEntryApiTest { + + @Autowired + private TestRestTemplate restTemplate; + + private TimeEntry timeEntry = new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8); + + @Test + public void testCreate() throws Exception { + ResponseEntity createResponse = restTemplate.postForEntity("/time-entries", timeEntry, String.class); + + + assertThat(createResponse.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + DocumentContext createJson = parse(createResponse.getBody()); + assertThat(createJson.read("$.id", Long.class)).isGreaterThan(0); + assertThat(createJson.read("$.projectId", Long.class)).isEqualTo(123L); + assertThat(createJson.read("$.userId", Long.class)).isEqualTo(456L); + assertThat(createJson.read("$.date", String.class)).isEqualTo("2017-01-08"); + assertThat(createJson.read("$.hours", Long.class)).isEqualTo(8); + } + + @Test + public void testList() throws Exception { + Long id = createTimeEntry(); + + + ResponseEntity listResponse = restTemplate.getForEntity("/time-entries", String.class); + + + assertThat(listResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + DocumentContext listJson = parse(listResponse.getBody()); + + Collection timeEntries = listJson.read("$[*]", Collection.class); + assertThat(timeEntries.size()).isEqualTo(1); + + Long readId = listJson.read("$[0].id", Long.class); + assertThat(readId).isEqualTo(id); + } + + @Test + public void testRead() throws Exception { + Long id = createTimeEntry(); + + + ResponseEntity readResponse = this.restTemplate.getForEntity("/time-entries/" + id, String.class); + + + assertThat(readResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + DocumentContext readJson = parse(readResponse.getBody()); + assertThat(readJson.read("$.id", Long.class)).isEqualTo(id); + assertThat(readJson.read("$.projectId", Long.class)).isEqualTo(123L); + assertThat(readJson.read("$.userId", Long.class)).isEqualTo(456L); + assertThat(readJson.read("$.date", String.class)).isEqualTo("2017-01-08"); + assertThat(readJson.read("$.hours", Long.class)).isEqualTo(8); + } + + @Test + public void testUpdate() throws Exception { + Long id = createTimeEntry(); + TimeEntry updatedTimeEntry = new TimeEntry(2, 3, LocalDate.parse("2017-01-09"), 9); + + + ResponseEntity updateResponse = restTemplate.exchange("/time-entries/" + id, HttpMethod.PUT, new HttpEntity<>(updatedTimeEntry, null), String.class); + + + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + + DocumentContext updateJson = parse(updateResponse.getBody()); + assertThat(updateJson.read("$.id", Long.class)).isEqualTo(id); + assertThat(updateJson.read("$.projectId", Long.class)).isEqualTo(2L); + assertThat(updateJson.read("$.userId", Long.class)).isEqualTo(3L); + assertThat(updateJson.read("$.date", String.class)).isEqualTo("2017-01-09"); + assertThat(updateJson.read("$.hours", Long.class)).isEqualTo(9); + } + + @Test + public void testDelete() throws Exception { + Long id = createTimeEntry(); + + + ResponseEntity deleteResponse = restTemplate.exchange("/time-entries/" + id, HttpMethod.DELETE, null, String.class); + + + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + ResponseEntity deletedReadResponse = this.restTemplate.getForEntity("/time-entries/" + id, String.class); + assertThat(deletedReadResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + private Long createTimeEntry() { + HttpEntity entity = new HttpEntity<>(timeEntry); + + ResponseEntity response = restTemplate.exchange("/time-entries", HttpMethod.POST, entity, TimeEntry.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + return response.getBody().getId(); + } +} From 16a3341962c9e092ff821bf9b801430462a365cd Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 13:55:04 -0700 Subject: [PATCH 11/22] Spring MVC updates --- build.gradle | 1 + .../io/pivotal/pal/PalTrackerApplication.java | 12 -- .../tracker/InMemoryTimeEntryRepository.java | 42 +++++++ .../pal/tracker/PalTrackerApplication.java | 32 +++++ .../io/pivotal/pal/tracker/TimeEntry.java | 114 ++++++++++++++++++ .../pal/tracker/TimeEntryController.java | 58 +++++++++ .../pal/tracker/TimeEntryRepository.java | 11 ++ 7 files changed, 258 insertions(+), 12 deletions(-) delete mode 100644 src/main/java/io/pivotal/pal/PalTrackerApplication.java create mode 100644 src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java create mode 100644 src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java create mode 100644 src/main/java/io/pivotal/pal/tracker/TimeEntry.java create mode 100644 src/main/java/io/pivotal/pal/tracker/TimeEntryController.java create mode 100644 src/main/java/io/pivotal/pal/tracker/TimeEntryRepository.java diff --git a/build.gradle b/build.gradle index e4d95d306..65ebae9cd 100644 --- a/build.gradle +++ b/build.gradle @@ -9,6 +9,7 @@ repositories { dependencies { compile("org.springframework.boot:spring-boot-starter-web") + compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1") testCompile("org.springframework.boot:spring-boot-starter-test") } diff --git a/src/main/java/io/pivotal/pal/PalTrackerApplication.java b/src/main/java/io/pivotal/pal/PalTrackerApplication.java deleted file mode 100644 index 80f2a72a5..000000000 --- a/src/main/java/io/pivotal/pal/PalTrackerApplication.java +++ /dev/null @@ -1,12 +0,0 @@ -package io.pivotal.pal.tracker; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; - -@SpringBootApplication -public class PalTrackerApplication { - - public static void main(String[] args) { - SpringApplication.run(PalTrackerApplication.class, args); - } -} \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java b/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java new file mode 100644 index 000000000..dfa55dd75 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java @@ -0,0 +1,42 @@ +package io.pivotal.pal.tracker; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +/** + * Created by e018538 on 11/8/17. + */ +public class InMemoryTimeEntryRepository implements TimeEntryRepository { + + private HashMap timeEntries = new HashMap<>(); + + @Override + public TimeEntry create(TimeEntry timeEntry) { + timeEntry.setId(timeEntries.size() + 1); + timeEntries.put(timeEntry.getId(), timeEntry); + return timeEntry; + } + + @Override + public TimeEntry find(Long id) { + return timeEntries.get(id); + } + + @Override + public List list() { + return new ArrayList<>(timeEntries.values()); + } + + @Override + public TimeEntry update(Long id, TimeEntry timeEntry) { + timeEntries.replace(id, timeEntry); + timeEntry.setId(id); + return timeEntry; + } + + @Override + public void delete(Long id) { + timeEntries.remove(id); + } +} diff --git a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java new file mode 100644 index 000000000..79687f20d --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java @@ -0,0 +1,32 @@ +package io.pivotal.pal.tracker; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; + +@SpringBootApplication +public class PalTrackerApplication { + + public static void main(String[] args) { + SpringApplication.run(PalTrackerApplication.class, args); + } + + @Bean + public TimeEntryRepository timeEntryRepository() { + return new InMemoryTimeEntryRepository(); + } + + @Bean + public ObjectMapper jsonObjectMapper() { + return Jackson2ObjectMapperBuilder.json() + .serializationInclusion(JsonInclude.Include.NON_NULL) // Don’t include null values + .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) //ISODate + .modules(new JavaTimeModule()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/TimeEntry.java b/src/main/java/io/pivotal/pal/tracker/TimeEntry.java new file mode 100644 index 000000000..217d1c997 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntry.java @@ -0,0 +1,114 @@ +package io.pivotal.pal.tracker; + + +import java.time.LocalDate; + +/** + * Created by e018538 on 11/8/17. + */ + +public class TimeEntry { + private long id; + private long projectId; + private long userId; + private LocalDate date; + private int hours; + + + public TimeEntry() { + } + + public TimeEntry(long projectId, long userId, LocalDate date, int hours) { + this.projectId = projectId; + this.userId = userId; + this.date = date; + this.hours = hours; + } + + public TimeEntry(long id, long projectId, long userId, LocalDate date, int hours) { + this.id = id; + this.projectId = projectId; + this.userId = userId; + this.date = date; + this.hours = hours; + } + + public void setId(long id) { + + this.id = id; + } + + public void setProjectId(long projectId) { + this.projectId = projectId; + } + + public void setUserId(long userId) { + this.userId = userId; + } + + public void setDate(LocalDate date) { + this.date = date; + } + + public void setHours(int hours) { + this.hours = hours; + } + + public long getId() { + + return id; + } + + public long getProjectId() { + return projectId; + } + + public long getUserId() { + return userId; + } + + public LocalDate getDate() { + return date; + } + + public int getHours() { + return hours; + } + + @Override + public String toString() { + return "TimeEntry{" + + "id=" + id + + ", projectId=" + projectId + + ", userId=" + userId + + ", date=" + date + + ", hours=" + hours + + '}'; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + + TimeEntry timeEntry = (TimeEntry) o; + + if (id != timeEntry.id) return false; + if (projectId != timeEntry.projectId) return false; + if (userId != timeEntry.userId) return false; + if (hours != timeEntry.hours) return false; + return date != null ? date.equals(timeEntry.date) : timeEntry.date == null; + } + + @Override + public int hashCode() { + int result = (int) (id ^ (id >>> 32)); + result = 31 * result + (int) (projectId ^ (projectId >>> 32)); + result = 31 * result + (int) (userId ^ (userId >>> 32)); + result = 31 * result + (date != null ? date.hashCode() : 0); + result = 31 * result + hours; + return result; + } + + +} diff --git a/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java new file mode 100644 index 000000000..9cdd6dcd6 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java @@ -0,0 +1,58 @@ +package io.pivotal.pal.tracker; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Created by e018538 on 11/8/17. + */ +@RestController +@RequestMapping("/time-entries") +public class TimeEntryController { + + private TimeEntryRepository repository; + + public TimeEntryController(TimeEntryRepository timeEntriesRepo) { + this.repository = timeEntriesRepo; + } + + @GetMapping + public ResponseEntity> list() { + return new ResponseEntity<>(repository.list(), HttpStatus.OK); + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeEntry entry) { + TimeEntry anEntry = repository.create(entry); + return new ResponseEntity(anEntry, HttpStatus.CREATED); + } + + @PutMapping("{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry entry) { + TimeEntry updatedEntry = repository.update(id, entry); + if(updatedEntry != null) { + return new ResponseEntity(updatedEntry, HttpStatus.OK); + } else { + return new ResponseEntity(HttpStatus.NOT_FOUND); + } + } + + @GetMapping("{id}") + public ResponseEntity read(@PathVariable Long id) { + TimeEntry entry = repository.find(id); + if(entry != null) { + return new ResponseEntity(entry, HttpStatus.OK); + } else { + return new ResponseEntity(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("{id}") + public ResponseEntity delete(@PathVariable Long id) { + repository.delete(id); + return new ResponseEntity(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/io/pivotal/pal/tracker/TimeEntryRepository.java b/src/main/java/io/pivotal/pal/tracker/TimeEntryRepository.java new file mode 100644 index 000000000..866097db4 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryRepository.java @@ -0,0 +1,11 @@ +package io.pivotal.pal.tracker; + +import java.util.List; + +public interface TimeEntryRepository { + TimeEntry create(TimeEntry timeEntry); + TimeEntry find(Long id); + List list(); + TimeEntry update(Long id, TimeEntry timeEntry); + void delete(Long id); +} \ No newline at end of file From 5d9bb4894fe5953ce11d58d17c9fd75f963abb03 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 11:45:04 -0600 Subject: [PATCH 12/22] Add tests for JDBC lab --- .../tracker/JdbcTimeEntryRepositoryTest.java | 159 ++++++++++++++++++ .../pal/trackerapi/TimeEntryApiTest.java | 12 ++ 2 files changed, 171 insertions(+) create mode 100644 src/test/java/test/pivotal/pal/tracker/JdbcTimeEntryRepositoryTest.java diff --git a/src/test/java/test/pivotal/pal/tracker/JdbcTimeEntryRepositoryTest.java b/src/test/java/test/pivotal/pal/tracker/JdbcTimeEntryRepositoryTest.java new file mode 100644 index 000000000..e1eac20fc --- /dev/null +++ b/src/test/java/test/pivotal/pal/tracker/JdbcTimeEntryRepositoryTest.java @@ -0,0 +1,159 @@ +package test.pivotal.pal.tracker; + + +import com.mysql.cj.jdbc.MysqlDataSource; +import io.pivotal.pal.tracker.JdbcTimeEntryRepository; +import io.pivotal.pal.tracker.TimeEntry; +import io.pivotal.pal.tracker.TimeEntryRepository; +import org.junit.Before; +import org.junit.Test; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Date; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JdbcTimeEntryRepositoryTest { + private TimeEntryRepository subject; + private JdbcTemplate jdbcTemplate; + + @Before + public void setUp() throws Exception { + MysqlDataSource dataSource = new MysqlDataSource(); + dataSource.setUrl(System.getenv("SPRING_DATASOURCE_URL")); + + subject = new JdbcTimeEntryRepository(dataSource); + + jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute("DELETE FROM time_entries"); + + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + + @Test + public void createInsertsATimeEntryRecord() throws Exception { + TimeEntry newTimeEntry = new TimeEntry(123, 321, LocalDate.parse("2017-01-09"), 8); + TimeEntry entry = subject.create(newTimeEntry); + + Map foundEntry = jdbcTemplate.queryForMap("Select * from time_entries where id = ?", entry.getId()); + + assertThat(foundEntry.get("id")).isEqualTo(entry.getId()); + assertThat(foundEntry.get("project_id")).isEqualTo(123L); + assertThat(foundEntry.get("user_id")).isEqualTo(321L); + assertThat(((Date)foundEntry.get("date")).toLocalDate()).isEqualTo(LocalDate.parse("2017-01-09")); + assertThat(foundEntry.get("hours")).isEqualTo(8); + } + + @Test + public void createReturnsTheCreatedTimeEntry() throws Exception { + TimeEntry newTimeEntry = new TimeEntry(123, 321, LocalDate.parse("2017-01-09"), 8); + TimeEntry entry = subject.create(newTimeEntry); + + assertThat(entry.getId()).isNotNull(); + assertThat(entry.getProjectId()).isEqualTo(123); + assertThat(entry.getUserId()).isEqualTo(321); + assertThat(entry.getDate()).isEqualTo(LocalDate.parse("2017-01-09")); + assertThat(entry.getHours()).isEqualTo(8); + } + + @Test + public void findFindsATimeEntry() throws Exception { + jdbcTemplate.execute( + "INSERT INTO time_entries (id, project_id, user_id, date, hours) " + + "VALUES (999, 123, 321, '2017-01-09', 8)" + ); + + TimeEntry timeEntry = subject.find(999L); + + assertThat(timeEntry.getId()).isEqualTo(999L); + assertThat(timeEntry.getProjectId()).isEqualTo(123L); + assertThat(timeEntry.getUserId()).isEqualTo(321L); + assertThat(timeEntry.getDate()).isEqualTo(LocalDate.parse("2017-01-09")); + assertThat(timeEntry.getHours()).isEqualTo(8); + } + + @Test + public void findReturnsNullWhenNotFound() throws Exception { + TimeEntry timeEntry = subject.find(999L); + + assertThat(timeEntry).isNull(); + } + + @Test + public void listFindsAllTimeEntries() throws Exception { + jdbcTemplate.execute( + "INSERT INTO time_entries (id, project_id, user_id, date, hours) " + + "VALUES (999, 123, 321, '2017-01-09', 8), (888, 456, 678, '2017-01-08', 9)" + ); + + List timeEntries = subject.list(); + assertThat(timeEntries.size()).isEqualTo(2); + + TimeEntry timeEntry = timeEntries.get(0); + assertThat(timeEntry.getId()).isEqualTo(888L); + assertThat(timeEntry.getProjectId()).isEqualTo(456L); + assertThat(timeEntry.getUserId()).isEqualTo(678L); + assertThat(timeEntry.getDate()).isEqualTo(LocalDate.parse("2017-01-08")); + assertThat(timeEntry.getHours()).isEqualTo(9); + + timeEntry = timeEntries.get(1); + assertThat(timeEntry.getId()).isEqualTo(999L); + assertThat(timeEntry.getProjectId()).isEqualTo(123L); + assertThat(timeEntry.getUserId()).isEqualTo(321L); + assertThat(timeEntry.getDate()).isEqualTo(LocalDate.parse("2017-01-09")); + assertThat(timeEntry.getHours()).isEqualTo(8); + } + + @Test + public void updateReturnsTheUpdatedRecord() throws Exception { + jdbcTemplate.execute( + "INSERT INTO time_entries (id, project_id, user_id, date, hours) " + + "VALUES (1000, 123, 321, '2017-01-09', 8)"); + + TimeEntry timeEntryUpdates = new TimeEntry(456, 987, LocalDate.parse("2017-01-10"), 10); + + TimeEntry updatedTimeEntry = subject.update(1000L, timeEntryUpdates); + + assertThat(updatedTimeEntry.getId()).isEqualTo(1000L); + assertThat(updatedTimeEntry.getProjectId()).isEqualTo(456L); + assertThat(updatedTimeEntry.getUserId()).isEqualTo(987L); + assertThat(updatedTimeEntry.getDate()).isEqualTo(LocalDate.parse("2017-01-10")); + assertThat(updatedTimeEntry.getHours()).isEqualTo(10); + } + + @Test + public void updateUpdatesTheRecord() throws Exception { + jdbcTemplate.execute( + "INSERT INTO time_entries (id, project_id, user_id, date, hours) " + + "VALUES (1000, 123, 321, '2017-01-09', 8)"); + + TimeEntry updatedTimeEntry = new TimeEntry(456, 322, LocalDate.parse("2017-01-10"), 10); + + TimeEntry timeEntry = subject.update(1000L, updatedTimeEntry); + + Map foundEntry = jdbcTemplate.queryForMap("Select * from time_entries where id = ?", timeEntry.getId()); + + assertThat(foundEntry.get("id")).isEqualTo(timeEntry.getId()); + assertThat(foundEntry.get("project_id")).isEqualTo(456L); + assertThat(foundEntry.get("user_id")).isEqualTo(322L); + assertThat(((Date)foundEntry.get("date")).toLocalDate()).isEqualTo(LocalDate.parse("2017-01-10")); + assertThat(foundEntry.get("hours")).isEqualTo(10); + } + + @Test + public void deleteRemovesTheRecord() throws Exception { + jdbcTemplate.execute( + "INSERT INTO time_entries (id, project_id, user_id, date, hours) " + + "VALUES (999, 123, 321, '2017-01-09', 8)" + ); + + subject.delete(999L); + + Map foundEntry = jdbcTemplate.queryForMap("Select count(*) count from time_entries where id = ?", 999); + assertThat(foundEntry.get("count")).isEqualTo(0L); + } +} diff --git a/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java index b742e5447..2b7464304 100644 --- a/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java +++ b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java @@ -1,8 +1,10 @@ package test.pivotal.pal.trackerapi; import com.jayway.jsonpath.DocumentContext; +import com.mysql.cj.jdbc.MysqlDataSource; import io.pivotal.pal.tracker.PalTrackerApplication; import io.pivotal.pal.tracker.TimeEntry; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; @@ -12,6 +14,7 @@ import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.junit4.SpringRunner; import java.time.LocalDate; @@ -30,6 +33,15 @@ public class TimeEntryApiTest { private TimeEntry timeEntry = new TimeEntry(123, 456, LocalDate.parse("2017-01-08"), 8); + @Before + public void setUp() throws Exception { + MysqlDataSource dataSource = new MysqlDataSource(); + dataSource.setUrl(System.getenv("SPRING_DATASOURCE_URL")); + + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); + jdbcTemplate.execute("TRUNCATE time_entries"); + } + @Test public void testCreate() throws Exception { ResponseEntity createResponse = restTemplate.postForEntity("/time-entries", timeEntry, String.class); From 38359f8ce6fd69f2a7bebc0324090335eb672435 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:02:26 -0700 Subject: [PATCH 13/22] Spring MVC updates --- build.gradle | 22 +++++ ci/build.yml | 13 ++- manifest-production.yml | 2 + manifest-review.yml | 2 + .../pal/tracker/JdbcTimeEntryRepository.java | 88 +++++++++++++++++++ .../pal/tracker/PalTrackerApplication.java | 6 +- 6 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/JdbcTimeEntryRepository.java diff --git a/build.gradle b/build.gradle index 65ebae9cd..608a754f3 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,9 @@ +import org.flywaydb.gradle.task.FlywayMigrateTask + plugins { id "java" id "org.springframework.boot" version "1.5.8.RELEASE" + id "org.flywaydb.flyway" version "4.2.0" } repositories { @@ -10,13 +13,32 @@ repositories { dependencies { compile("org.springframework.boot:spring-boot-starter-web") compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1") + compile("org.springframework.boot:spring-boot-starter-jdbc") + + compile("mysql:mysql-connector-java:6.0.6") + testCompile("org.springframework.boot:spring-boot-starter-test") } +def developmentDbUrl = "jdbc:mysql://localhost:3306/tracker_dev?user=tracker&useSSL=false&useTimezone=true&serverTimezone=UTC&useLegacyDatetimeCode=false" bootRun.environment([ "WELCOME_MESSAGE": "hello", + "SPRING_DATASOURCE_URL": developmentDbUrl, ]) +def testDbUrl = "jdbc:mysql://localhost:3306/tracker_test?user=tracker&useSSL=false&useTimezone=true&serverTimezone=UTC&useLegacyDatetimeCode=false" test.environment([ "WELCOME_MESSAGE": "Hello from test", + "SPRING_DATASOURCE_URL": testDbUrl, ]) + +flyway { + url = developmentDbUrl + user = "tracker" + password = "" + locations = ["filesystem:databases/tracker/migrations"] +} + +task testMigrate(type: FlywayMigrateTask) { + url = testDbUrl +} \ No newline at end of file diff --git a/ci/build.yml b/ci/build.yml index e2bb9473a..ea12986b1 100644 --- a/ci/build.yml +++ b/ci/build.yml @@ -18,7 +18,18 @@ run: args: - -exc - | + + export DEBIAN_FRONTEND="noninteractive" + + apt-get update + + apt-get -y install mysql-server + service mysql start + cd pal-tracker + mysql -uroot < databases/create_databases.sql chmod +x gradlew - ./gradlew -P version=$(cat ../version/number) build + ./gradlew testMigrate clean build || (service mysql stop && exit 1) + service mysql stop + cp build/libs/pal-tracker-*.jar ../build-output \ No newline at end of file diff --git a/manifest-production.yml b/manifest-production.yml index 7a64d1606..2a1782d1b 100644 --- a/manifest-production.yml +++ b/manifest-production.yml @@ -5,3 +5,5 @@ applications: host: mjs-pal-tracker # env: # WELCOME_MESSAGE: Hello from Cloud Foundry + services: + - tracker-database diff --git a/manifest-review.yml b/manifest-review.yml index 175eab850..40cfe5247 100644 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -5,3 +5,5 @@ applications: host: mjs-pal-tracker-review # env: # WELCOME_MESSAGE: Hello from Cloud Foundry + services: + - tracker-database diff --git a/src/main/java/io/pivotal/pal/tracker/JdbcTimeEntryRepository.java b/src/main/java/io/pivotal/pal/tracker/JdbcTimeEntryRepository.java new file mode 100644 index 000000000..67b4370d0 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/JdbcTimeEntryRepository.java @@ -0,0 +1,88 @@ +package io.pivotal.pal.tracker; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; + +import javax.sql.DataSource; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.util.List; + +import static java.sql.Statement.RETURN_GENERATED_KEYS; + +public class JdbcTimeEntryRepository implements TimeEntryRepository { + + private final JdbcTemplate jdbcTemplate; + + public JdbcTimeEntryRepository(DataSource dataSource) { + this.jdbcTemplate = new JdbcTemplate(dataSource); + } + + @Override + public TimeEntry create(TimeEntry timeEntry) { + KeyHolder generatedKeyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement( + "INSERT INTO time_entries (project_id, user_id, date, hours) " + + "VALUES (?, ?, ?, ?)", + RETURN_GENERATED_KEYS + ); + + statement.setLong(1, timeEntry.getProjectId()); + statement.setLong(2, timeEntry.getUserId()); + statement.setDate(3, Date.valueOf(timeEntry.getDate())); + statement.setInt(4, timeEntry.getHours()); + + return statement; + }, generatedKeyHolder); + + return find(generatedKeyHolder.getKey().longValue()); + } + + @Override + public TimeEntry find(Long id) { + return jdbcTemplate.query( + "SELECT id, project_id, user_id, date, hours FROM time_entries WHERE id = ?", + new Object[]{id}, + extractor); + } + + @Override + public List list() { + return jdbcTemplate.query("SELECT id, project_id, user_id, date, hours FROM time_entries", mapper); + } + + @Override + public TimeEntry update(Long id, TimeEntry timeEntry) { + jdbcTemplate.update("UPDATE time_entries " + + "SET project_id = ?, user_id = ?, date = ?, hours = ? " + + "WHERE id = ?", + timeEntry.getProjectId(), + timeEntry.getUserId(), + Date.valueOf(timeEntry.getDate()), + timeEntry.getHours(), + id); + + return find(id); + } + + @Override + public void delete(Long id) { + jdbcTemplate.update("DELETE FROM time_entries WHERE id = ?", id); + } + + private final RowMapper mapper = (rs, rowNum) -> new TimeEntry( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("user_id"), + rs.getDate("date").toLocalDate(), + rs.getInt("hours") + ); + + private final ResultSetExtractor extractor = + (rs) -> rs.next() ? mapper.mapRow(rs, 1) : null; +} \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java index 79687f20d..c133f0ff8 100644 --- a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java +++ b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java @@ -9,6 +9,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder; +import javax.sql.DataSource; + @SpringBootApplication public class PalTrackerApplication { @@ -17,8 +19,8 @@ public static void main(String[] args) { } @Bean - public TimeEntryRepository timeEntryRepository() { - return new InMemoryTimeEntryRepository(); + public TimeEntryRepository timeEntryRepository(DataSource datasource) { + return new JdbcTimeEntryRepository(datasource); } @Bean From fb870592f6fc7fc7065f6d413f3d04cad62fe846 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:08:43 -0700 Subject: [PATCH 14/22] Spring MVC updates --- ci/build.yml | 2 +- databases/tracker/V1__initial_schema.sql | 11 +++++++++++ databases/tracker/create_databases.sql | 10 ++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 databases/tracker/V1__initial_schema.sql create mode 100644 databases/tracker/create_databases.sql diff --git a/ci/build.yml b/ci/build.yml index ea12986b1..34f1ebd73 100644 --- a/ci/build.yml +++ b/ci/build.yml @@ -27,7 +27,7 @@ run: service mysql start cd pal-tracker - mysql -uroot < databases/create_databases.sql + mysql -uroot < databases/tracker/create_databases.sql chmod +x gradlew ./gradlew testMigrate clean build || (service mysql stop && exit 1) service mysql stop diff --git a/databases/tracker/V1__initial_schema.sql b/databases/tracker/V1__initial_schema.sql new file mode 100644 index 000000000..3e605c0b5 --- /dev/null +++ b/databases/tracker/V1__initial_schema.sql @@ -0,0 +1,11 @@ +CREATE TABLE time_entries ( + id BIGINT(20) NOT NULL AUTO_INCREMENT, + project_id BIGINT(20), + user_id BIGINT(20), + date DATE, + hours INT, + + PRIMARY KEY (id) +) + ENGINE = innodb + DEFAULT CHARSET = utf8; diff --git a/databases/tracker/create_databases.sql b/databases/tracker/create_databases.sql new file mode 100644 index 000000000..a0674bdda --- /dev/null +++ b/databases/tracker/create_databases.sql @@ -0,0 +1,10 @@ +DROP DATABASE IF EXISTS tracker_dev; +DROP DATABASE IF EXISTS tracker_test; + +CREATE DATABASE tracker_dev; +CREATE DATABASE tracker_test; + +CREATE USER IF NOT EXISTS 'tracker'@'localhost' + IDENTIFIED BY ''; +GRANT ALL PRIVILEGES ON tracker_dev.* TO 'tracker' @'localhost'; +GRANT ALL PRIVILEGES ON tracker_test.* TO 'tracker' @'localhost'; From 5f9f42ec8456179bb034ef33290020f9d9dce6b1 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:16:18 -0700 Subject: [PATCH 15/22] Spring MVC updates --- manifest-review.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/manifest-review.yml b/manifest-review.yml index 40cfe5247..9afcacef0 100644 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -7,3 +7,4 @@ applications: # WELCOME_MESSAGE: Hello from Cloud Foundry services: - tracker-database +#### From 78c38cdbe9b9ba389fad29f87f1f13287db64c78 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:26:08 -0700 Subject: [PATCH 16/22] Spring MVC updates --- databases/tracker/{ => migrations}/V1__initial_schema.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename databases/tracker/{ => migrations}/V1__initial_schema.sql (100%) diff --git a/databases/tracker/V1__initial_schema.sql b/databases/tracker/migrations/V1__initial_schema.sql similarity index 100% rename from databases/tracker/V1__initial_schema.sql rename to databases/tracker/migrations/V1__initial_schema.sql From 8ac8f93b793fff4b87022e33c31a643db6a07275 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:40:41 -0700 Subject: [PATCH 17/22] Spring MVC updates --- manifest-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest-review.yml b/manifest-review.yml index 9afcacef0..ca2bf9bc3 100644 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -7,4 +7,4 @@ applications: # WELCOME_MESSAGE: Hello from Cloud Foundry services: - tracker-database -#### +### From 9a7699ba9076a9c12572260e5704b59bee77371a Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Wed, 8 Nov 2017 16:45:31 -0700 Subject: [PATCH 18/22] Spring MVC updates --- ci/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/build.yml b/ci/build.yml index 34f1ebd73..2455d052b 100644 --- a/ci/build.yml +++ b/ci/build.yml @@ -29,7 +29,7 @@ run: cd pal-tracker mysql -uroot < databases/tracker/create_databases.sql chmod +x gradlew - ./gradlew testMigrate clean build || (service mysql stop && exit 1) + ./gradlew -P version=$(cat ../version/number) testMigrate clean build || (service mysql stop && exit 1) service mysql stop cp build/libs/pal-tracker-*.jar ../build-output \ No newline at end of file From 6856d71edb3d84cea04f9c66b0495cba3cf37656 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 12:28:32 -0600 Subject: [PATCH 19/22] Add tests for Actuator lab --- .../pivotal/pal/trackerapi/HealthApiTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java diff --git a/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java new file mode 100644 index 000000000..b3eef23cc --- /dev/null +++ b/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java @@ -0,0 +1,38 @@ +package test.pivotal.pal.trackerapi; + +import com.jayway.jsonpath.DocumentContext; +import io.pivotal.pal.tracker.PalTrackerApplication; +import org.junit.Test; +import org.junit.runner.RunWith; +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.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit4.SpringRunner; + +import static com.jayway.jsonpath.JsonPath.parse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) +public class HealthApiTest { + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void healthTest() { + ResponseEntity response = this.restTemplate.getForEntity("/health", String.class); + + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + DocumentContext healthJson = parse(response.getBody()); + + assertThat(healthJson.read("$.status", String.class)).isEqualTo("UP"); + assertThat(healthJson.read("$.db.status", String.class)).isEqualTo("UP"); + assertThat(healthJson.read("$.diskSpace.status", String.class)).isEqualTo("UP"); + } +} From 3aea01f7cc20a518e22cf2605d88208777753def Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Thu, 9 Nov 2017 09:40:31 -0700 Subject: [PATCH 20/22] Spring MVC updates --- build.gradle | 3 ++ .../pal/tracker/TimeEntryController.java | 15 +++++++++- .../pal/tracker/TimeEntryHealthIndicator.java | 29 +++++++++++++++++++ .../pal/tracker/TimeEntryControllerTest.java | 8 ++++- 4 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/TimeEntryHealthIndicator.java diff --git a/build.gradle b/build.gradle index 608a754f3..3921a4eca 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,7 @@ dependencies { compile("org.springframework.boot:spring-boot-starter-web") compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1") compile("org.springframework.boot:spring-boot-starter-jdbc") + compile("org.springframework.boot:spring-boot-starter-actuator") compile("mysql:mysql-connector-java:6.0.6") @@ -24,12 +25,14 @@ def developmentDbUrl = "jdbc:mysql://localhost:3306/tracker_dev?user=tracker&use bootRun.environment([ "WELCOME_MESSAGE": "hello", "SPRING_DATASOURCE_URL": developmentDbUrl, + "MANAGEMENT_SECURITY_ENABLED": false, ]) def testDbUrl = "jdbc:mysql://localhost:3306/tracker_test?user=tracker&useSSL=false&useTimezone=true&serverTimezone=UTC&useLegacyDatetimeCode=false" test.environment([ "WELCOME_MESSAGE": "Hello from test", "SPRING_DATASOURCE_URL": testDbUrl, + "MANAGEMENT_SECURITY_ENABLED": false, ]) flyway { diff --git a/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java index 9cdd6dcd6..1d958312c 100644 --- a/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java @@ -1,5 +1,7 @@ package io.pivotal.pal.tracker; +import org.springframework.boot.actuate.metrics.CounterService; +import org.springframework.boot.actuate.metrics.GaugeService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -14,19 +16,27 @@ public class TimeEntryController { private TimeEntryRepository repository; + private CounterService counter; + private GaugeService gauge; - public TimeEntryController(TimeEntryRepository timeEntriesRepo) { + public TimeEntryController(TimeEntryRepository timeEntriesRepo, CounterService counterService, GaugeService gaugeService) { this.repository = timeEntriesRepo; + this.counter = counterService; + this.gauge = gaugeService; } @GetMapping public ResponseEntity> list() { + counter.increment("TimeEntry.listed"); return new ResponseEntity<>(repository.list(), HttpStatus.OK); } @PostMapping public ResponseEntity create(@RequestBody TimeEntry entry) { TimeEntry anEntry = repository.create(entry); + counter.increment("TimeEntry.created"); + gauge.submit("timeEntries.count", repository.list().size()); + return new ResponseEntity(anEntry, HttpStatus.CREATED); } @@ -34,6 +44,7 @@ public ResponseEntity create(@RequestBody TimeEntry entry) { public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry entry) { TimeEntry updatedEntry = repository.update(id, entry); if(updatedEntry != null) { + counter.increment("TimeEntry.updated"); return new ResponseEntity(updatedEntry, HttpStatus.OK); } else { return new ResponseEntity(HttpStatus.NOT_FOUND); @@ -44,6 +55,7 @@ public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry entry public ResponseEntity read(@PathVariable Long id) { TimeEntry entry = repository.find(id); if(entry != null) { + counter.increment("TimeEntry.read"); return new ResponseEntity(entry, HttpStatus.OK); } else { return new ResponseEntity(HttpStatus.NOT_FOUND); @@ -53,6 +65,7 @@ public ResponseEntity read(@PathVariable Long id) { @DeleteMapping("{id}") public ResponseEntity delete(@PathVariable Long id) { repository.delete(id); + counter.increment("TimeEntry.deleted"); return new ResponseEntity(HttpStatus.NO_CONTENT); } } diff --git a/src/main/java/io/pivotal/pal/tracker/TimeEntryHealthIndicator.java b/src/main/java/io/pivotal/pal/tracker/TimeEntryHealthIndicator.java new file mode 100644 index 000000000..49b1f175d --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryHealthIndicator.java @@ -0,0 +1,29 @@ +package io.pivotal.pal.tracker; + +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.stereotype.Component; + +@Component +public class TimeEntryHealthIndicator implements HealthIndicator { + + private static final int MAX_TIME_ENTRIES = 5; + private final TimeEntryRepository timeEntryRepo; + + public TimeEntryHealthIndicator(TimeEntryRepository timeEntryRepo) { + this.timeEntryRepo = timeEntryRepo; + } + + @Override + public Health health() { + Health.Builder builder = new Health.Builder(); + + if(timeEntryRepo.list().size() < MAX_TIME_ENTRIES) { + builder.up(); + } else { + builder.down(); + } + + return builder.build(); + } +} \ No newline at end of file diff --git a/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java b/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java index f7c0090e3..9e3cdd036 100644 --- a/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java +++ b/src/test/java/test/pivotal/pal/tracker/TimeEntryControllerTest.java @@ -5,6 +5,8 @@ import io.pivotal.pal.tracker.TimeEntryRepository; import org.junit.Before; import org.junit.Test; +import org.springframework.boot.actuate.metrics.CounterService; +import org.springframework.boot.actuate.metrics.GaugeService; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -20,11 +22,15 @@ public class TimeEntryControllerTest { private TimeEntryRepository timeEntryRepository; private TimeEntryController controller; + private CounterService counterService; + private GaugeService gaugeService; @Before public void setUp() throws Exception { timeEntryRepository = mock(TimeEntryRepository.class); - controller = new TimeEntryController(timeEntryRepository); + counterService = mock(CounterService.class); + gaugeService = mock(GaugeService.class); + controller = new TimeEntryController(timeEntryRepository, counterService, gaugeService); } @Test From ac6b1744c032e6f722f0df70b49f1c6c8e264845 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 12:50:47 -0600 Subject: [PATCH 21/22] Add tests for Security lab --- .../pal/trackerapi/SecurityApiTest.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/test/java/test/pivotal/pal/trackerapi/SecurityApiTest.java diff --git a/src/test/java/test/pivotal/pal/trackerapi/SecurityApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/SecurityApiTest.java new file mode 100644 index 000000000..72099994b --- /dev/null +++ b/src/test/java/test/pivotal/pal/trackerapi/SecurityApiTest.java @@ -0,0 +1,52 @@ +package test.pivotal.pal.trackerapi; + +import io.pivotal.pal.tracker.PalTrackerApplication; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.embedded.LocalServerPort; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) +public class SecurityApiTest { + + @LocalServerPort + private String port; + private TestRestTemplate authorizedRestTemplate; + + @Autowired + private TestRestTemplate unAuthorizedRestTemplate; + + @Before + public void setUp() throws Exception { + RestTemplateBuilder builder = new RestTemplateBuilder() + .rootUri("http://localhost:" + port) + .basicAuthorization("user", "password"); + + authorizedRestTemplate = new TestRestTemplate(builder); + } + + @Test + public void unauthorizedTest() { + ResponseEntity response = this.unAuthorizedRestTemplate.getForEntity("/", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + public void authorizedTest() { + ResponseEntity response = this.authorizedRestTemplate.getForEntity("/", String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + } +} From d4edd43e8976a9095f45b9677a8ceda70bc8a416 Mon Sep 17 00:00:00 2001 From: Mike Shaon Date: Thu, 9 Nov 2017 10:54:00 -0700 Subject: [PATCH 22/22] Spring MVC updates --- build.gradle | 3 ++ manifest-production.yml | 3 +- manifest-review.yml | 3 +- .../pal/tracker/PalTrackerApplication.java | 1 + .../pal/tracker/SecurityConfiguration.java | 49 +++++++++++++++++++ .../pivotal/pal/trackerapi/HealthApiTest.java | 13 +++++ .../pal/trackerapi/TimeEntryApiTest.java | 9 ++++ .../pal/trackerapi/WelcomeApiTest.java | 13 +++++ 8 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java diff --git a/build.gradle b/build.gradle index 3921a4eca..935db97e5 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,9 @@ dependencies { compile("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.1") compile("org.springframework.boot:spring-boot-starter-jdbc") compile("org.springframework.boot:spring-boot-starter-actuator") + compile("org.springframework.boot:spring-boot-actuator-docs") + compile("org.springframework.boot:spring-boot-starter-security") + compile("org.projectlombok:lombok") compile("mysql:mysql-connector-java:6.0.6") diff --git a/manifest-production.yml b/manifest-production.yml index 2a1782d1b..8b4de2b3d 100644 --- a/manifest-production.yml +++ b/manifest-production.yml @@ -3,7 +3,8 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar host: mjs-pal-tracker -# env: + env: + SECURITY_FORCE_HTTPS: true # WELCOME_MESSAGE: Hello from Cloud Foundry services: - tracker-database diff --git a/manifest-review.yml b/manifest-review.yml index ca2bf9bc3..23e988bab 100644 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -3,7 +3,8 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar host: mjs-pal-tracker-review -# env: + env: + SECURITY_FORCE_HTTPS: true # WELCOME_MESSAGE: Hello from Cloud Foundry services: - tracker-database diff --git a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java index c133f0ff8..768ca7549 100644 --- a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java +++ b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java @@ -31,4 +31,5 @@ public ObjectMapper jsonObjectMapper() { .modules(new JavaTimeModule()) .build(); } + } \ No newline at end of file diff --git a/src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java b/src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java new file mode 100644 index 000000000..7d8c0ccb3 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java @@ -0,0 +1,49 @@ +package io.pivotal.pal.tracker; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; + +/** + * Created by e018538 on 11/9/17. + */ + +@EnableWebSecurity +@Slf4j +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + + @Value("${SECURITY_FORCE_HTTPS:false}") + private boolean forceHttps; + + @Override + public void configure(HttpSecurity http) throws Exception { + + if(forceHttps) { + log.info("https on"); + http.requiresChannel() + .anyRequest() + .requiresSecure(); + } else { + log.info("https off"); + } + + http.authorizeRequests() + .antMatchers("/**") + .hasRole("USER") + .and() + .httpBasic() + .and() + .csrf().disable(); + } + + @Autowired + public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + auth + .inMemoryAuthentication() + .withUser("user").password("password").roles("USER"); + } +} diff --git a/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java index b3eef23cc..f20b85f76 100644 --- a/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java +++ b/src/test/java/test/pivotal/pal/trackerapi/HealthApiTest.java @@ -2,11 +2,14 @@ import com.jayway.jsonpath.DocumentContext; import io.pivotal.pal.tracker.PalTrackerApplication; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.test.context.junit4.SpringRunner; @@ -19,9 +22,19 @@ @SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) public class HealthApiTest { + @LocalServerPort + private String port; + @Autowired private TestRestTemplate restTemplate; + @Before + public void setUp() throws Exception { + RestTemplateBuilder builder = new RestTemplateBuilder().rootUri("http://localhost:" + port) + .basicAuthorization("user", "password"); + restTemplate = new TestRestTemplate(builder); + } + @Test public void healthTest() { ResponseEntity response = this.restTemplate.getForEntity("/health", String.class); diff --git a/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java index 2b7464304..7b8e49cd7 100644 --- a/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java +++ b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java @@ -8,8 +8,10 @@ import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpEntity; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -28,6 +30,9 @@ @SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) public class TimeEntryApiTest { + @LocalServerPort + private String port; + @Autowired private TestRestTemplate restTemplate; @@ -40,6 +45,10 @@ public void setUp() throws Exception { JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); jdbcTemplate.execute("TRUNCATE time_entries"); + + RestTemplateBuilder builder = new RestTemplateBuilder().rootUri("http://localhost:" + port) + .basicAuthorization("user", "password"); + restTemplate = new TestRestTemplate(builder); } @Test diff --git a/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java index cc7091ed4..c111e0cc8 100644 --- a/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java +++ b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java @@ -1,11 +1,14 @@ package test.pivotal.pal.trackerapi; import io.pivotal.pal.tracker.PalTrackerApplication; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.embedded.LocalServerPort; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.test.context.junit4.SpringRunner; import static org.assertj.core.api.Assertions.assertThat; @@ -15,9 +18,19 @@ @SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) public class WelcomeApiTest { + @LocalServerPort + private String port; + @Autowired private TestRestTemplate restTemplate; + @Before + public void setUp() throws Exception { + RestTemplateBuilder builder = new RestTemplateBuilder().rootUri("http://localhost:" + port) + .basicAuthorization("user", "password"); + restTemplate = new TestRestTemplate(builder); + } + @Test public void exampleTest() { String body = this.restTemplate.getForObject("/", String.class);