From f1c11281dc67e5c61a5d0c66cfdb6d6aca9c1eba Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Fri, 21 Jul 2017 09:26:37 -0600 Subject: [PATCH 01/21] 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 4f4aa502e37ae23a02f041b3d3de5b1ef2c24594 Mon Sep 17 00:00:00 2001 From: e067411 Date: Tue, 19 Dec 2017 11:37:32 -0500 Subject: [PATCH 02/21] Simple Spring Boot app --- .../pivotal/pal/tracker/PalTrackerApplication.java | 12 ++++++++++++ .../io/pivotal/pal/tracker/WelcomeController.java | 14 ++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java create mode 100644 src/main/java/io/pivotal/pal/tracker/WelcomeController.java 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..80f2a72a5 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/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..9aa0ea1cf --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java @@ -0,0 +1,14 @@ +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"; + } +} + From 1e0c41e2a5bf15764729e7a580566163967aa9ac Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Thu, 20 Jul 2017 13:56:50 -0600 Subject: [PATCH 03/21] 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 3ab225844d8fdd284c1a5d317cb72e867d13fad4 Mon Sep 17 00:00:00 2001 From: e067411 Date: Tue, 19 Dec 2017 15:39:46 -0500 Subject: [PATCH 04/21] added concourse stuff --- build.gradle | 22 +++ ci/build.yml | 23 +++ ci/pipeline.yml | 31 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54224 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 171 ++++++++++++++++++ gradlew.bat | 84 +++++++++ manifest.yml | 7 + settings.gradle | 1 + .../io/pivotal/pal/tracker/EnvController.java | 41 +++++ .../pal/tracker/WelcomeController.java | 11 +- 11 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 build.gradle create mode 100755 ci/build.yml create mode 100755 ci/pipeline.yml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 manifest.yml create mode 100644 settings.gradle create mode 100644 src/main/java/io/pivotal/pal/tracker/EnvController.java diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..a130a318b --- /dev/null +++ b/build.gradle @@ -0,0 +1,22 @@ +plugins { + id "java" + id "org.springframework.boot" version "1.5.4.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/ci/build.yml b/ci/build.yml new file mode 100755 index 000000000..c2303fed4 --- /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 \ No newline at end of file diff --git a/ci/pipeline.yml b/ci/pipeline.yml new file mode 100755 index 000000000..01d389043 --- /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" \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d6e2637affb74a80bfbe87bd2da57e81b2f3c661 GIT binary patch literal 54224 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⋘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%0D_j(+P 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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..64f4c3608 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Dec 19 11:18:37 EST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.2-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..4ef3a871f --- /dev/null +++ b/gradlew @@ -0,0 +1,171 @@ +#!/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 +for s in "${@}" ; do + s=\"$s\" + APP_ARGS=$APP_ARGS" "$s +done + +# 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/manifest.yml b/manifest.yml new file mode 100644 index 000000000..7f06f91ee --- /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/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 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..b8c0167d2 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/EnvController.java @@ -0,0 +1,41 @@ +package io.pivotal.pal.tracker; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +public class EnvController { + + private final String port; + private final String memoryLimit; + private final String cfInstanceIndex; + private final String cfInstanceAddress; + + 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.cfInstanceAddress = cfInstanceAddress; + } + + @GetMapping("/env") + public Map getEnv() { + Map env = new HashMap<>(); + + env.put("PORT", port); + env.put("MEMORY_LIMIT", memoryLimit); + env.put("CF_INSTANCE_INDEX", cfInstanceIndex); + env.put("CF_INSTANCE_ADDR", cfInstanceAddress); + + 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 9aa0ea1cf..ea8be03dd 100644 --- a/src/main/java/io/pivotal/pal/tracker/WelcomeController.java +++ b/src/main/java/io/pivotal/pal/tracker/WelcomeController.java @@ -1,14 +1,23 @@ package io.pivotal.pal.tracker; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class WelcomeController { + private String message; + + public WelcomeController(@Value("${WELCOME_MESSAGE}") String message) { + this.message=message; + } @GetMapping("/") public String sayHello() { - return "hello"; + return message; } + + + } From 541a5e5df47c0686bd2943cfab88db7041817b85 Mon Sep 17 00:00:00 2001 From: e067411 Date: Tue, 19 Dec 2017 16:10:56 -0500 Subject: [PATCH 05/21] modified message --- manifest.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/manifest.yml b/manifest.yml index 7f06f91ee..2cefccc45 100644 --- a/manifest.yml +++ b/manifest.yml @@ -3,5 +3,3 @@ 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 e4e9a0af0fcb39f388710df9b2f4ac1011e7976d Mon Sep 17 00:00:00 2001 From: e067411 Date: Tue, 19 Dec 2017 16:50:05 -0500 Subject: [PATCH 06/21] Pipelines for multiple environments --- ci/build.yml | 5 ++-- ci/pipeline.yml | 66 ++++++++++++++++++++++++++++++++++++----- manifest-production.yml | 5 ++++ manifest-review.yml | 5 ++++ 4 files changed, 72 insertions(+), 9 deletions(-) create mode 100755 manifest-production.yml create mode 100755 manifest-review.yml diff --git a/ci/build.yml b/ci/build.yml index c2303fed4..4ba28db53 100755 --- 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 \ No newline at end of file + ./gradlew -P version=$(cat ../version/number) build + cp build/libs/pal-tracker-*.jar ../build-output diff --git a/ci/pipeline.yml b/ci/pipeline.yml index 01d389043..c34c3b2a9 100755 --- 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" \ No newline at end of file + WELCOME_MESSAGE: "Hello from the production environment" \ No newline at end of file diff --git a/manifest-production.yml b/manifest-production.yml new file mode 100755 index 000000000..193682320 --- /dev/null +++ b/manifest-production.yml @@ -0,0 +1,5 @@ +--- +applications: +- name: pal-tracker + path: build/libs/pal-tracker.jar + host: ps-pal-tracker diff --git a/manifest-review.yml b/manifest-review.yml new file mode 100755 index 000000000..65369e833 --- /dev/null +++ b/manifest-review.yml @@ -0,0 +1,5 @@ +--- +applications: +- name: pal-tracker + path: build/libs/pal-tracker.jar + host: ps-pal-tracker-review From 5fc5239fe1ea1af62c8d380c16ac09aa8ea67d29 Mon Sep 17 00:00:00 2001 From: e067411 Date: Tue, 19 Dec 2017 17:00:27 -0500 Subject: [PATCH 07/21] added some stuff --- ci/build.yml | 2 +- manifest-production.yml | 2 +- manifest-review.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/build.yml b/ci/build.yml index 4ba28db53..e2bb9473a 100755 --- a/ci/build.yml +++ b/ci/build.yml @@ -21,4 +21,4 @@ run: cd pal-tracker chmod +x gradlew ./gradlew -P version=$(cat ../version/number) build - cp build/libs/pal-tracker-*.jar ../build-output + 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 193682320..41cdede63 100755 --- a/manifest-production.yml +++ b/manifest-production.yml @@ -2,4 +2,4 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker + host: ps-pal-tracker-pramod diff --git a/manifest-review.yml b/manifest-review.yml index 65369e833..2f5378a93 100755 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -2,4 +2,4 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker-review + host: ps-pal-tracker-review-pramod From 803c8e38641c038bea69649bbdfa8e4448a326d6 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Thu, 20 Jul 2017 15:04:20 -0600 Subject: [PATCH 08/21] 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 bea5bafa34477bf6bfd6946b5972745ddb852693 Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 10:04:08 -0500 Subject: [PATCH 09/21] added Rest endpoints --- build.gradle | 1 + .../tracker/InMemoryTimeEntryRepository.java | 38 ++++++++ .../pal/tracker/PalTrackerApplication.java | 20 +++++ .../io/pivotal/pal/tracker/TimeEntry.java | 88 +++++++++++++++++++ .../pal/tracker/TimeEntryController.java | 57 ++++++++++++ .../pal/tracker/TimeEntryRepository.java | 11 +++ 6 files changed, 215 insertions(+) create mode 100644 src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.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 a130a318b..fc9d50dbc 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/tracker/InMemoryTimeEntryRepository.java b/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java new file mode 100644 index 000000000..07785c3e0 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java @@ -0,0 +1,38 @@ +package io.pivotal.pal.tracker; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +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); + } +} \ 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 80f2a72a5..78cb5b60f 100644 --- a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java +++ b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java @@ -1,7 +1,13 @@ 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 { @@ -9,4 +15,18 @@ public class PalTrackerApplication { public static void main(String[] args) { SpringApplication.run(PalTrackerApplication.class, args); } + + @Bean + 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..abc194422 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntry.java @@ -0,0 +1,88 @@ +package io.pivotal.pal.tracker; + +import java.time.LocalDate; + +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 long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public long getProjectId() { + return projectId; + } + + public long getUserId() { + return userId; + } + + public LocalDate getDate() { + return date; + } + + public int getHours() { + return 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; + } + + @Override + public String toString() { + return "TimeEntry{" + + "id=" + id + + ", projectId=" + projectId + + ", userId=" + userId + + ", date='" + date + '\'' + + ", hours=" + hours + + '}'; + } +} 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..1f460951a --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java @@ -0,0 +1,57 @@ +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; + +@RestController +@RequestMapping("/time-entries") +public class TimeEntryController { + + private TimeEntryRepository timeEntriesRepo; + + public TimeEntryController(TimeEntryRepository timeEntriesRepo) { + this.timeEntriesRepo = timeEntriesRepo; + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeEntry timeEntry) { + TimeEntry createdTimeEntry = timeEntriesRepo.create(timeEntry); + + return new ResponseEntity<>(createdTimeEntry, HttpStatus.CREATED); + } + + @GetMapping("{id}") + public ResponseEntity read(@PathVariable Long id) { + TimeEntry timeEntry = timeEntriesRepo.find(id); + if (timeEntry != null) { + return new ResponseEntity<>(timeEntry, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @GetMapping + public ResponseEntity> list() { + return new ResponseEntity<>(timeEntriesRepo.list(), HttpStatus.OK); + } + + @PutMapping("{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry timeEntry) { + TimeEntry updatedTimeEntry = timeEntriesRepo.update(id, timeEntry); + if (updatedTimeEntry != null) { + return new ResponseEntity<>(updatedTimeEntry, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("{id}") + public ResponseEntity delete(@PathVariable Long id) { + timeEntriesRepo.delete(id); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} \ No newline at end of file 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 cb05880f3ff4d211bce83ff14cfe60591b923971 Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 12:19:30 -0500 Subject: [PATCH 10/21] database migration stuff. --- build.gradle | 1 + database/create_databases.sql | 10 +++ .../tracker/migrations/V1__initial_schema.sql | 11 +++ .../tracker/InMemoryTimeEntryRepository.java | 38 ++++++++ .../pal/tracker/PalTrackerApplication.java | 20 +++++ .../io/pivotal/pal/tracker/TimeEntry.java | 88 +++++++++++++++++++ .../pal/tracker/TimeEntryController.java | 57 ++++++++++++ .../pal/tracker/TimeEntryRepository.java | 11 +++ 8 files changed, 236 insertions(+) create mode 100644 database/create_databases.sql create mode 100644 database/tracker/migrations/V1__initial_schema.sql create mode 100644 src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.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 a130a318b..fc9d50dbc 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/database/create_databases.sql b/database/create_databases.sql new file mode 100644 index 000000000..6f1e86abf --- /dev/null +++ b/database/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'; \ No newline at end of file diff --git a/database/tracker/migrations/V1__initial_schema.sql b/database/tracker/migrations/V1__initial_schema.sql new file mode 100644 index 000000000..daca8c4e3 --- /dev/null +++ b/database/tracker/migrations/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; \ 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..07785c3e0 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/InMemoryTimeEntryRepository.java @@ -0,0 +1,38 @@ +package io.pivotal.pal.tracker; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +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); + } +} \ 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 80f2a72a5..78cb5b60f 100644 --- a/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java +++ b/src/main/java/io/pivotal/pal/tracker/PalTrackerApplication.java @@ -1,7 +1,13 @@ 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 { @@ -9,4 +15,18 @@ public class PalTrackerApplication { public static void main(String[] args) { SpringApplication.run(PalTrackerApplication.class, args); } + + @Bean + 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..abc194422 --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntry.java @@ -0,0 +1,88 @@ +package io.pivotal.pal.tracker; + +import java.time.LocalDate; + +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 long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + public long getProjectId() { + return projectId; + } + + public long getUserId() { + return userId; + } + + public LocalDate getDate() { + return date; + } + + public int getHours() { + return 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; + } + + @Override + public String toString() { + return "TimeEntry{" + + "id=" + id + + ", projectId=" + projectId + + ", userId=" + userId + + ", date='" + date + '\'' + + ", hours=" + hours + + '}'; + } +} 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..1f460951a --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/TimeEntryController.java @@ -0,0 +1,57 @@ +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; + +@RestController +@RequestMapping("/time-entries") +public class TimeEntryController { + + private TimeEntryRepository timeEntriesRepo; + + public TimeEntryController(TimeEntryRepository timeEntriesRepo) { + this.timeEntriesRepo = timeEntriesRepo; + } + + @PostMapping + public ResponseEntity create(@RequestBody TimeEntry timeEntry) { + TimeEntry createdTimeEntry = timeEntriesRepo.create(timeEntry); + + return new ResponseEntity<>(createdTimeEntry, HttpStatus.CREATED); + } + + @GetMapping("{id}") + public ResponseEntity read(@PathVariable Long id) { + TimeEntry timeEntry = timeEntriesRepo.find(id); + if (timeEntry != null) { + return new ResponseEntity<>(timeEntry, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @GetMapping + public ResponseEntity> list() { + return new ResponseEntity<>(timeEntriesRepo.list(), HttpStatus.OK); + } + + @PutMapping("{id}") + public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry timeEntry) { + TimeEntry updatedTimeEntry = timeEntriesRepo.update(id, timeEntry); + if (updatedTimeEntry != null) { + return new ResponseEntity<>(updatedTimeEntry, HttpStatus.OK); + } else { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } + + @DeleteMapping("{id}") + public ResponseEntity delete(@PathVariable Long id) { + timeEntriesRepo.delete(id); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} \ No newline at end of file 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 f154e8286a91a77dec2aa1d191b9a3c0da776b9d Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 11:45:04 -0600 Subject: [PATCH 11/21] Add tests for JDBC lab --- .../tracker/JdbcTimeEntryRepositoryTest.java | 159 ++++++++++++++++++ 1 file changed, 159 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); + } +} From 75d80a79d32fee1bd7ee557e66600434104c7b1c Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 14:34:51 -0500 Subject: [PATCH 12/21] JDBC Template stuff --- build.gradle | 22 +++++ ci/build.yml | 13 ++- manifest-review.yml | 4 +- .../pal/tracker/JdbcTimeEntryRepository.java | 88 +++++++++++++++++++ .../pal/trackerapi/TimeEntryApiTest.java | 17 +++- 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/JdbcTimeEntryRepository.java diff --git a/build.gradle b/build.gradle index fc9d50dbc..82b6bfb88 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,10 @@ + +import org.flywaydb.gradle.task.FlywayMigrateTask + plugins { id "java" id "org.springframework.boot" version "1.5.4.RELEASE" + id "org.flywaydb.flyway" version "4.2.0" } repositories { @@ -10,14 +14,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 +} diff --git a/ci/build.yml b/ci/build.yml index e2bb9473a..2455d052b 100755 --- 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/tracker/create_databases.sql chmod +x gradlew - ./gradlew -P version=$(cat ../version/number) build + ./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 diff --git a/manifest-review.yml b/manifest-review.yml index 2f5378a93..897eba97d 100755 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -2,4 +2,6 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker-review-pramod + host: ps-pal-tracker-review + services: + - tracker-database \ No newline at end of file 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/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/TimeEntryApiTest.java index b742e5447..4d0c137b2 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,10 +14,12 @@ 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; import java.util.Collection; +import java.util.TimeZone; import static com.jayway.jsonpath.JsonPath.parse; import static org.assertj.core.api.Assertions.assertThat; @@ -30,6 +34,17 @@ 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"); + + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + } + @Test public void testCreate() throws Exception { ResponseEntity createResponse = restTemplate.postForEntity("/time-entries", timeEntry, String.class); @@ -123,4 +138,4 @@ private Long createTimeEntry() { return response.getBody().getId(); } -} +} \ No newline at end of file From 3171d990d1b675385f241ad27df43feba32cac48 Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 12:28:32 -0600 Subject: [PATCH 13/21] 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 946584e27c20f76da92d6bf5e21c7d8a200ec797 Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 14:49:50 -0500 Subject: [PATCH 14/21] actuator stuff --- build.gradle | 7 +++++ .../pal/tracker/TimeEntryController.java | 19 +++++++++++- .../pal/tracker/TimeEntryHealthIndicator.java | 29 +++++++++++++++++++ .../pal/tracker/TimeEntryControllerTest.java | 10 +++++-- 4 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/TimeEntryHealthIndicator.java diff --git a/build.gradle b/build.gradle index 82b6bfb88..67b7a0aca 100644 --- a/build.gradle +++ b/build.gradle @@ -19,18 +19,25 @@ dependencies { compile("mysql:mysql-connector-java:6.0.6") testCompile("org.springframework.boot:spring-boot-starter-test") + compile("org.springframework.boot:spring-boot-starter-actuator") +} + +springBoot { + buildInfo() } 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, + "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 1f460951a..427ed4aaf 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.*; @@ -10,15 +12,25 @@ @RequestMapping("/time-entries") public class TimeEntryController { + private final CounterService counter; + private final GaugeService gauge; private TimeEntryRepository timeEntriesRepo; - public TimeEntryController(TimeEntryRepository timeEntriesRepo) { + public TimeEntryController( + TimeEntryRepository timeEntriesRepo, + CounterService counter, + GaugeService gauge + ) { this.timeEntriesRepo = timeEntriesRepo; + this.counter = counter; + this.gauge = gauge; } @PostMapping public ResponseEntity create(@RequestBody TimeEntry timeEntry) { TimeEntry createdTimeEntry = timeEntriesRepo.create(timeEntry); + counter.increment("TimeEntry.created"); + gauge.submit("timeEntries.count", timeEntriesRepo.list().size()); return new ResponseEntity<>(createdTimeEntry, HttpStatus.CREATED); } @@ -27,6 +39,7 @@ public ResponseEntity create(@RequestBody TimeEntry timeEntry) { public ResponseEntity read(@PathVariable Long id) { TimeEntry timeEntry = timeEntriesRepo.find(id); if (timeEntry != null) { + counter.increment("TimeEntry.read"); return new ResponseEntity<>(timeEntry, HttpStatus.OK); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); @@ -35,6 +48,7 @@ public ResponseEntity read(@PathVariable Long id) { @GetMapping public ResponseEntity> list() { + counter.increment("TimeEntry.listed"); return new ResponseEntity<>(timeEntriesRepo.list(), HttpStatus.OK); } @@ -42,6 +56,7 @@ public ResponseEntity> list() { public ResponseEntity update(@PathVariable Long id, @RequestBody TimeEntry timeEntry) { TimeEntry updatedTimeEntry = timeEntriesRepo.update(id, timeEntry); if (updatedTimeEntry != null) { + counter.increment("TimeEntry.updated"); return new ResponseEntity<>(updatedTimeEntry, HttpStatus.OK); } else { return new ResponseEntity<>(HttpStatus.NOT_FOUND); @@ -51,6 +66,8 @@ public ResponseEntity update(@PathVariable Long id, @RequestBody Time @DeleteMapping("{id}") public ResponseEntity delete(@PathVariable Long id) { timeEntriesRepo.delete(id); + counter.increment("TimeEntry.deleted"); + gauge.submit("timeEntries.count", timeEntriesRepo.list().size()); 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..fda5a0fbd 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 @@ -103,4 +109,4 @@ public void testDelete() throws Exception { verify(timeEntryRepository).delete(1L); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); } -} +} \ No newline at end of file From d9a12718f4130fb22156a40a8fb2eea2e08c011e Mon Sep 17 00:00:00 2001 From: Tyson Gern Date: Wed, 26 Jul 2017 12:50:47 -0600 Subject: [PATCH 15/21] 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 df327aceb4b6bd9a4f06257b508e38a6081290ef Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 16:00:38 -0500 Subject: [PATCH 16/21] security stuff --- build.gradle | 1 + manifest-production.yml | 2 ++ manifest-review.yml | 4 ++- .../pal/tracker/SecurityConfiguration.java | 32 +++++++++++++++++++ .../pivotal/pal/trackerapi/HealthApiTest.java | 15 ++++++++- .../pal/trackerapi/TimeEntryApiTest.java | 9 ++++++ .../pal/trackerapi/WelcomeApiTest.java | 18 +++++++++-- 7 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java diff --git a/build.gradle b/build.gradle index 67b7a0aca..49c736106 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,7 @@ dependencies { testCompile("org.springframework.boot:spring-boot-starter-test") compile("org.springframework.boot:spring-boot-starter-actuator") + compile("org.springframework.boot:spring-boot-starter-security") } springBoot { diff --git a/manifest-production.yml b/manifest-production.yml index 41cdede63..f8562541d 100755 --- a/manifest-production.yml +++ b/manifest-production.yml @@ -3,3 +3,5 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar host: ps-pal-tracker-pramod + env: + SECURITY_FORCE_HTTPS: true diff --git a/manifest-review.yml b/manifest-review.yml index 897eba97d..30281a3fe 100755 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -4,4 +4,6 @@ applications: path: build/libs/pal-tracker.jar host: ps-pal-tracker-review services: - - tracker-database \ No newline at end of file + - tracker-database + env: + SECURITY_FORCE_HTTPS: true \ 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..527b4564b --- /dev/null +++ b/src/main/java/io/pivotal/pal/tracker/SecurityConfiguration.java @@ -0,0 +1,32 @@ +package io.pivotal.pal.tracker; + +import org.springframework.beans.factory.annotation.Autowired; +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; + +@EnableWebSecurity +public class SecurityConfiguration extends WebSecurityConfigurerAdapter { + @Override + protected void configure(HttpSecurity http) throws Exception { + String forceHttps = System.getenv("SECURITY_FORCE_HTTPS"); + if (forceHttps != null && forceHttps.equals("true")) { + http.requiresChannel().anyRequest().requiresSecure(); + } + + 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..ab57dd835 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 { - @Autowired + + @LocalServerPort + private String port; 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 4d0c137b2..22a971cef 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; @@ -29,6 +31,8 @@ @SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) public class TimeEntryApiTest { + @LocalServerPort + private String port; @Autowired private TestRestTemplate restTemplate; @@ -39,6 +43,11 @@ public void setUp() throws Exception { MysqlDataSource dataSource = new MysqlDataSource(); dataSource.setUrl(System.getenv("SPRING_DATASOURCE_URL")); + RestTemplateBuilder builder = new RestTemplateBuilder() + .rootUri("http://localhost:" + port) + .basicAuthorization("user", "password"); + + restTemplate = new TestRestTemplate(builder); JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource); jdbcTemplate.execute("TRUNCATE time_entries"); diff --git a/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java index cc7091ed4..782c5d64d 100644 --- a/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java +++ b/src/test/java/test/pivotal/pal/trackerapi/WelcomeApiTest.java @@ -1,11 +1,13 @@ 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,12 +17,22 @@ @SpringBootTest(classes = PalTrackerApplication.class, webEnvironment = RANDOM_PORT) public class WelcomeApiTest { - @Autowired + @LocalServerPort + private String port; 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); assertThat(body).isEqualTo("Hello from test"); } -} +} \ No newline at end of file From 6f1bf7676897a9fb2c31951c6f750db3bf07fd65 Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 16:18:29 -0500 Subject: [PATCH 17/21] corrected db folder name --- ci/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/build.yml b/ci/build.yml index 2455d052b..28e423804 100755 --- a/ci/build.yml +++ b/ci/build.yml @@ -27,7 +27,7 @@ run: service mysql start cd pal-tracker - mysql -uroot < databases/tracker/create_databases.sql + mysql -uroot < database/tracker/create_databases.sql chmod +x gradlew ./gradlew -P version=$(cat ../version/number) testMigrate clean build || (service mysql stop && exit 1) service mysql stop From ea2d793c14afddee52103f51dc1b82e98dcf71c9 Mon Sep 17 00:00:00 2001 From: e067411 Date: Wed, 20 Dec 2017 16:50:36 -0500 Subject: [PATCH 18/21] corrected db file corrected --- database/{ => tracker}/create_databases.sql | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename database/{ => tracker}/create_databases.sql (100%) diff --git a/database/create_databases.sql b/database/tracker/create_databases.sql similarity index 100% rename from database/create_databases.sql rename to database/tracker/create_databases.sql From 6c5ae5799219d4195f8027fcd03687265129230a Mon Sep 17 00:00:00 2001 From: e067411 Date: Thu, 21 Dec 2017 09:42:38 -0500 Subject: [PATCH 19/21] database name corrected in gradle file --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 49c736106..be458a724 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ bootRun.environment([ "SPRING_DATASOURCE_URL": developmentDbUrl, "MANAGEMENT_SECURITY_ENABLED": false, ]) - +g 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", @@ -45,7 +45,7 @@ flyway { url = developmentDbUrl user = "tracker" password = "" - locations = ["filesystem:databases/tracker/migrations"] + locations = ["filesystem:database/tracker/migrations"] } task testMigrate(type: FlywayMigrateTask) { From f8cefd9fb6d95e09899faf992973d98a75002e45 Mon Sep 17 00:00:00 2001 From: e067411 Date: Thu, 21 Dec 2017 09:47:27 -0500 Subject: [PATCH 20/21] removed g --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index be458a724..48c7549bb 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ bootRun.environment([ "SPRING_DATASOURCE_URL": developmentDbUrl, "MANAGEMENT_SECURITY_ENABLED": false, ]) -g + 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", From 241c0eea0bf8f7cf336a653da6e1747e399d4ea3 Mon Sep 17 00:00:00 2001 From: e067411 Date: Thu, 21 Dec 2017 10:13:57 -0500 Subject: [PATCH 21/21] revieew fix --- manifest-review.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manifest-review.yml b/manifest-review.yml index 30281a3fe..5bb2d3e91 100755 --- a/manifest-review.yml +++ b/manifest-review.yml @@ -2,7 +2,7 @@ applications: - name: pal-tracker path: build/libs/pal-tracker.jar - host: ps-pal-tracker-review + host: ps-pal-tracker-review-pramod services: - tracker-database env: