diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index ee0b5c7..bf9aea2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -41,6 +41,7 @@ jobs: - '25' app: - springboot3 + - springboot4 - quarkus3 - quarkus3-spring-compatibility name: "[jvm-build-test-java${{ matrix.java }}]: ${{ matrix.app }}" @@ -70,6 +71,9 @@ jobs: springboot3) TARGET_DIR="${{ matrix.app }}/target/springboot3.jar" ;; + springboot4) + TARGET_DIR="${{ matrix.app }}/target/springboot4.jar" + ;; quarkus3) TARGET_DIR="${{ matrix.app }}/target/quarkus-app/quarkus-run.jar" ;; diff --git a/pom.xml b/pom.xml index 0a192d1..32dd7c4 100644 --- a/pom.xml +++ b/pom.xml @@ -12,5 +12,6 @@ quarkus3 quarkus3-spring-compatibility springboot3 + springboot4 - \ No newline at end of file + diff --git a/scripts/perf-lab/main.yml b/scripts/perf-lab/main.yml index 8f5b0c9..5a3af8c 100644 --- a/scripts/perf-lab/main.yml +++ b/scripts/perf-lab/main.yml @@ -8,13 +8,15 @@ states: config.jvm.graalvm.version: 25.0.1-graalce config.quarkus.version: #3.28.3 - config.springboot.version: #3.5.6 + config.springboot3.version: #3.5.6 + config.springboot4.version: #4.0.0 config.jvm.memory: #-Xmx128m config.jvm.args: #-XX:+UseNUMA config.quarkus.native_build_options: #-Dquarkus.native.native-image-xmx= - config.springboot.native_build_options: + config.springboot3.native_build_options: + config.springboot4.native_build_options: config.resources.cpu.app: 0-3 config.resources.cpu.db: 4-6 @@ -39,7 +41,7 @@ states: PROFILER_JVM_ARGS: BASE_JAVA_CMD: ${{APP_CMD_PREFIX}} java ${{config.jvm.memory}} ${{config.jvm.args}} ${{PROFILER_JVM_ARGS}} TESTS : [test-build, measure-build-times, measure-time-to-first-request, measure-rss, run-load-test] - RUNTIMES: [quarkus3-jvm, quarkus3-native, spring3-jvm, spring3-jvm-aot, spring3-native] + RUNTIMES: [quarkus3-jvm, quarkus3-native, spring3-jvm, spring3-jvm-aot, spring3-native, spring4-jvm, spring4-jvm-aot, spring4-native] TARGET_URL: http://localhost:8080/fruits QUARKUS-PLATFORM-ARTIFACT-ID: quarkus-bom PROJ_REPO_NAME: spring-quarkus-perf-comparison @@ -64,23 +66,44 @@ states: type: jvm dir: ${{SPRING3_BOOT_DIR}} updateScript: update-spring-boot-version - updateVersion: ${{config.springboot.version}} + updateVersion: ${{config.springboot3.version}} buildCmd: "./mvnw clean package -DskipTests" runCmd: "${{BASE_JAVA_CMD}} -jar ${{SPRING3_BOOT_DIR}}/target/springboot3.jar" - name: spring3-jvm-aot type: jvm dir: ${{SPRING3_BOOT_DIR}} updateScript: update-spring-boot-version - updateVersion: ${{config.springboot.version}} + updateVersion: ${{config.springboot3.version}} buildCmd: "./mvnw clean compile spring-boot:process-aot package -DskipTests" runCmd: "${{BASE_JAVA_CMD}} -Dspring.aot.enabled=true -jar ${{SPRING3_BOOT_DIR}}/target/springboot3.jar" - name: spring3-native type: native dir: ${{SPRING3_BOOT_DIR}} updateScript: update-spring-boot-version - updateVersion: ${{config.springboot.version}} - buildCmd: "./mvnw clean -Pnative -DskipTests native:compile package ${{config.springboot.native_build_options}}" + updateVersion: ${{config.springboot3.version}} + buildCmd: "./mvnw clean -Pnative -DskipTests native:compile package ${{config.springboot3.native_build_options}}" runCmd: "${{APP_CMD_PREFIX}} ${{SPRING3_BOOT_DIR}}/target/springboot3 ${{config.jvm.memory}}" + - name: spring4-jvm + type: jvm + dir: ${{SPRING4_BOOT_DIR}} + updateScript: update-spring-boot-version + updateVersion: ${{config.springboot4.version}} + buildCmd: "./mvnw clean package -DskipTests" + runCmd: "${{BASE_JAVA_CMD}} -jar ${{SPRING4_BOOT_DIR}}/target/springboot4.jar" + - name: spring4-jvm-aot + type: jvm + dir: ${{SPRING4_BOOT_DIR}} + updateScript: update-spring-boot-version + updateVersion: ${{config.springboot4.version}} + buildCmd: "./mvnw clean compile spring-boot:process-aot package -DskipTests" + runCmd: "${{BASE_JAVA_CMD}} -Dspring.aot.enabled=true -jar ${{SPRING4_BOOT_DIR}}/target/springboot4.jar" + - name: spring4-native + type: native + dir: ${{SPRING4_BOOT_DIR}} + updateScript: update-spring-boot-version + updateVersion: ${{config.springboot4.version}} + buildCmd: "./mvnw clean -Pnative -DskipTests native:compile package ${{config.springboot4.native_build_options}}" + runCmd: "${{APP_CMD_PREFIX}} ${{SPRING4_BOOT_DIR}}/target/springboot4 ${{config.jvm.memory}}" scripts: update-state: @@ -90,8 +113,9 @@ scripts: - set-state: RUN.PROJ_REPO_DIR ${{REPO_DIR}}/${{PROJ_REPO_NAME}} - set-state: RUN.SCRIPTS_DIR ${{PROJ_REPO_DIR}}/scripts/perf-lab - set-state: RUN.HELPER_SCRIPTS_DIR ${{SCRIPTS_DIR}}/scripts - - set-state: RUN.SPRING3_BOOT_DIR ${{PROJ_REPO_DIR}}/springboot3 - - set-state: RUN.QUARKUS3_DIR ${{PROJ_REPO_DIR}}/quarkus3 + - set-state: RUN.SPRING3_BOOT_DIR ${{REPO_DIR}}/${{PROJ_REPO_NAME}}/springboot3 + - set-state: RUN.SPRING4_BOOT_DIR ${{REPO_DIR}}/${{PROJ_REPO_NAME}}/springboot4 + - set-state: RUN.QUARKUS3_DIR ${{REPO_DIR}}/${{PROJ_REPO_NAME}}/quarkus3 - set-state: RUN.ASYNC_PROFILER_DIR ${{BASE_DIR}}/${{ASYNC_PROFILER}} output-vars: @@ -102,6 +126,7 @@ scripts: "REPO_DIR: ${{REPO_DIR}}" "SCRIPTS_DIR: ${{SCRIPTS_DIR}}" "SPRING3_BOOT_DIR: ${{SPRING3_BOOT_DIR}}" + "SPRING4_BOOT_DIR: ${{SPRING4_BOOT_DIR}}" "QUARKUS3_DIR : ${{QUARKUS3_DIR}}" "RUNTIMECMDS: ${{RUNTIMECMDS}}" diff --git a/scripts/perf-lab/run-benchmarks.sh b/scripts/perf-lab/run-benchmarks.sh index 3b708f1..058cb04 100755 --- a/scripts/perf-lab/run-benchmarks.sh +++ b/scripts/perf-lab/run-benchmarks.sh @@ -9,48 +9,52 @@ help() { echo echo "Syntax: run-benchmarks.sh [options]" echo "options:" - echo " -a Any JVM args to be passed to the apps" - echo " -b The branch in the SCM repo" - echo " Default: '${SCM_REPO_BRANCH}'" - echo " -c How many CPUs to allocate to the application" - echo " Default: ${CPUS}" - echo " -d Purge/drop OS filesystem caches between iterations" - echo " -e Any extra arguments that need to be passed to qDup ahead of the qDup scripts" - echo " NOTE: This is an advanced option. Make sure you know what you are doing when using it." - echo " -f The directory containing the run output" - echo " Default: ${OUTPUT_DIR}" - echo " -g The GraalVM version to use if running any native tests (from SDKMAN)" - echo " Default: ${GRAALVM_VERSION}" - echo " -h The HOST to run the benchmarks on" - echo " LOCAL is a keyword that can be used to run everything on the local machine" - echo " Default: ${HOST}" - echo " -i The number of iterations to run each test" - echo " Default: ${ITERATIONS}" - echo " -j The Java version to use (from SDKMAN)" - echo " Default: ${JAVA_VERSION}" - echo " -l The SCM repo url" - echo " Default: '${SCM_REPO_URL}'" - echo " -n Native build options to be passed to Quarkus native build process" - echo " -o Native build options to be passed to Spring native build process" - echo " -p Enable profiling with async profiler" - echo " Accepted values: none, jfr, flamegraph" - echo " Default: ${PROFILER}" - echo " -q The Quarkus version to use" - echo " Default: Whatever version is set in pom.xml of the Quarkus app" - echo " NOTE: Its a good practice to set this manually to ensure proper version" - echo " -r The runtimes to test, separated by commas" - echo " Accepted values (1 or more of): quarkus3-jvm, quarkus3-native, spring3-jvm, spring3-jvm-aot, spring3-native" - echo " Default: 'quarkus3-jvm,quarkus3-native,spring3-jvm,spring3-jvm-aot,spring3-native'" - echo " -s The Spring Boot version to use" - echo " Default: Whatever version is set in pom.xml of the Spring Boot app" - echo " NOTE: Its a good practice to set this manually to ensure proper version" - echo " -t The tests to run, separated by commas" - echo " Accepted values (1 or more of): test-build, measure-build-times, measure-time-to-first-request, measure-rss, run-load-test" - echo " Default: 'test-build,measure-build-times,measure-time-to-first-request,measure-rss,run-load-test'" - echo " -u The user on to run the benchmark" - echo " -v JVM Memory setting (i.e. -Xmx -Xmn -Xms)" - echo " -w Wait time (in seconds) to wait for things like application startup" - echo " Default: ${WAIT_TIME}" + echo " --cpus How many CPUs to allocate to the application" + echo " Default: ${CPUS}" + echo " --drop-fs-caches Purge/drop OS filesystem caches between iterations" + echo " --extra-qdup-args Any extra arguments that need to be passed to qDup ahead of the qDup scripts" + echo " NOTE: This is an advanced option. Make sure you know what you are doing when using it." + echo " --graalvm-version The GraalVM version to use if running any native tests (from SDKMAN)" + echo " Default: ${GRAALVM_VERSION}" + echo " --host The HOST to run the benchmarks on" + echo " LOCAL is a keyword that can be used to run everything on the local machine" + echo " Default: ${HOST}" + echo " --iterations The number of iterations to run each test" + echo " Default: ${ITERATIONS}" + echo " --java-version The Java version to use (from SDKMAN)" + echo " Default: ${JAVA_VERSION}" + echo " --jvm-args Any JVM args to be passed to the apps" + echo " --jvm-memory JVM Memory setting (i.e. -Xmx -Xmn -Xms)" + echo " --native-quarkus-build-options Native build options to be passed to Quarkus native build process" + echo " --native-spring3-build-options Native build options to be passed to Spring 3.x native build process" + echo " --native-spring4-build-options Native build options to be passed to Spring 4.x native build process" + echo " --output-dir The directory containing the run output" + echo " Default: ${OUTPUT_DIR}" + echo " --profiler Enable profiling with async profiler" + echo " Accepted values: none, jfr, flamegraph" + echo " Default: ${PROFILER}" + echo " --quarkus-version The Quarkus version to use" + echo " Default: Whatever version is set in pom.xml of the Quarkus app" + echo " NOTE: Its a good practice to set this manually to ensure proper version" + echo " --repo-branch The branch in the SCM repo" + echo " Default: '${SCM_REPO_BRANCH}'" + echo " --repo-url The SCM repo url" + echo " Default: '${SCM_REPO_URL}'" + echo " --runtimes The runtimes to test, separated by commas" + echo " Accepted values (1 or more of): quarkus3-jvm, quarkus3-native, spring3-jvm, spring3-jvm-aot, spring3-native" + echo " Default: 'quarkus3-jvm,quarkus3-native,spring3-jvm,spring3-jvm-aot,spring3-native,spring4-jvm,spring4-jvm-aot,spring4-native'" + echo " --springboot3-version The Spring Boot 3.x version to use" + echo " Default: Whatever version is set in pom.xml of the Spring Boot 3 app" + echo " NOTE: Its a good practice to set this manually to ensure proper version" + echo " --springboot4-version The Spring Boot 4.x version to use" + echo " Default: Whatever version is set in pom.xml of the Spring Boot 4 app" + echo " NOTE: Its a good practice to set this manually to ensure proper version" + echo " --tests The tests to run, separated by commas" + echo " Accepted values (1 or more of): test-build, measure-build-times, measure-time-to-first-request, measure-rss, run-load-test" + echo " Default: 'test-build,measure-build-times,measure-time-to-first-request,measure-rss,run-load-test'" + echo " --user The user on to run the benchmark" + echo " --wait-time Wait time (in seconds) to wait for things like application startup" + echo " Default: ${WAIT_TIME}" } exit_abnormal() { @@ -65,16 +69,6 @@ validate_values() { exit_abnormal fi - if [ -z "$QUARKUS_VERSION" ]; then - echo "!! [ERROR] Please set the QUARKUS_VERSION!!" - exit_abnormal - fi - - if [ -z "$SPRING_BOOT_VERSION" ]; then - echo "!! [ERROR] Please set the SPRING_BOOT_VERSION!!" - exit_abnormal - fi - if [ "$HOST" != "LOCAL" -a -z "$USER" ]; then echo "!! [ERROR] Please set the USER!!" exit_abnormal @@ -100,11 +94,13 @@ print_values() { echo " ITERATIONS: $ITERATIONS" echo " JAVA_VERSION: $JAVA_VERSION" echo " NATIVE_QUARKUS_BUILD_OPTIONS: $NATIVE_QUARKUS_BUILD_OPTIONS" - echo " NATIVE_SPRING_BUILD_OPTIONS: $NATIVE_SPRING_BUILD_OPTIONS" + echo " NATIVE_SPRING3_BUILD_OPTIONS: $NATIVE_SPRING3_BUILD_OPTIONS" + echo " NATIVE_SPRING4_BUILD_OPTIONS: $NATIVE_SPRING4_BUILD_OPTIONS" echo " PROFILER: $PROFILER" echo " QUARKUS_VERSION: $QUARKUS_VERSION" echo " RUNTIMES: ${RUNTIMES[@]}" - echo " SPRING_BOOT_VERSION: $SPRING_BOOT_VERSION" + echo " SPRING_BOOT3_VERSION: $SPRING_BOOT3_VERSION" + echo " SPRING_BOOT4_VERSION: $SPRING_BOOT4_VERSION" echo " TESTS_TO_RUN: ${TESTS_TO_RUN[@]}" echo " USER: $USER" echo " JVM_MEMORY: $JVM_MEMORY" @@ -190,10 +186,12 @@ ${JBANG_CMD} qDup@hyperfoil \ -S config.resources.cpu.app="${app_cpus}" \ -S config.resources.cpu.db="${db_cpus}" \ -S config.resources.cpu.load_generator="${load_gen_cpus}" \ - -S config.springboot.version=${SPRING_BOOT_VERSION} \ + -S config.springboot3.version=${SPRING_BOOT3_VERSION} \ + -S config.springboot4.version=${SPRING_BOOT4_VERSION} \ -S config.jvm.memory="${JVM_MEMORY}" \ -S config.quarkus.version=${QUARKUS_VERSION} \ - -S config.springboot.native_build_options="${NATIVE_SPRING_BUILD_OPTIONS}" \ + -S config.springboot3.native_build_options="${NATIVE_SPRING3_BUILD_OPTIONS}" \ + -S config.springboot4.native_build_options="${NATIVE_SPRING4_BUILD_OPTIONS}" \ -S config.profiler.events=cpu \ -S config.repo.branch=${SCM_REPO_BRANCH} \ -S config.repo.url=${SCM_REPO_URL} \ @@ -217,12 +215,14 @@ HOST="LOCAL" ITERATIONS="3" JAVA_VERSION="25.0.1-tem" NATIVE_QUARKUS_BUILD_OPTIONS="" -NATIVE_SPRING_BUILD_OPTIONS="" +NATIVE_SPRING3_BUILD_OPTIONS="" +NATIVE_SPRING4_BUILD_OPTIONS="" PROFILER="none" QUARKUS_VERSION="" -ALLOWED_RUNTIMES=("quarkus3-jvm" "quarkus3-native" "spring3-jvm" "spring3-jvm-aot" "spring3-native") +ALLOWED_RUNTIMES=("quarkus3-jvm" "quarkus3-native" "spring3-jvm" "spring3-jvm-aot" "spring3-native" "spring4-jvm" "spring4-jvm-aot" "spring4-native") RUNTIMES=${ALLOWED_RUNTIMES[@]} -SPRING_BOOT_VERSION="" +SPRING_BOOT3_VERSION="" +SPRING_BOOT4_VERSION="" ALLOWED_TESTS_TO_RUN=("test-build" "measure-build-times" "measure-time-to-first-request" "measure-rss" "run-load-test") TESTS_TO_RUN=${ALLOWED_TESTS_TO_RUN[@]} USER="" @@ -233,96 +233,155 @@ JVM_ARGS="" EXTRA_QDUP_ARGS="" OUTPUT_DIR="/tmp" -# Process the inputs -while getopts "a:b:c:de:f:g:h:i:j:l:n:o:p:q:r:s:t:u:v:w:" option; do - case $option in - a) JVM_ARGS=$OPTARG +# Process the inputs - Manual parsing for portability +while [[ $# -gt 0 ]]; do + case "$1" in + --jvm-args) + JVM_ARGS="$2" + shift 2 ;; - b) SCM_REPO_BRANCH=$OPTARG + --repo-branch) + SCM_REPO_BRANCH="$2" + shift 2 ;; - c) CPUS=$OPTARG + --cpus) + CPUS="$2" + shift 2 ;; - d) DROP_OS_FILESYSTEM_CACHES=true + --drop-fs-caches) + DROP_OS_FILESYSTEM_CACHES=true + shift ;; - e) EXTRA_QDUP_ARGS=$OPTARG + --extra-qdup-args) + EXTRA_QDUP_ARGS="$2" + shift 2 ;; - f) OUTPUT_DIR=$OPTARG + --output-dir) + OUTPUT_DIR="$2" + shift 2 ;; - g) GRAALVM_VERSION=$OPTARG + --graalvm-version) + GRAALVM_VERSION="$2" + shift 2 ;; - h) HOST=$OPTARG + --host) + HOST="$2" + shift 2 ;; - i) ITERATIONS=$OPTARG + --iterations) + ITERATIONS="$2" + shift 2 ;; - j) JAVA_VERSION=$OPTARG + --java-version) + JAVA_VERSION="$2" + shift 2 ;; - l) SCM_REPO_URL=$OPTARG + --repo-url) + SCM_REPO_URL="$2" + shift 2 ;; - n) NATIVE_QUARKUS_BUILD_OPTIONS=$OPTARG + --native-quarkus-build-options) + NATIVE_QUARKUS_BUILD_OPTIONS="$2" + shift 2 ;; - o) NATIVE_SPRING_BUILD_OPTIONS=$OPTARG + --native-spring3-build-options) + NATIVE_SPRING3_BUILD_OPTIONS="$2" + shift 2 ;; - p) if [[ "$OPTARG" =~ ^(none|jfr|flamegraph)$ ]]; then - PROFILER=$OPTARG - else - echo "!! [ERROR] -p option must be one of (none, jfr, flamegraph)!!" - exit_abnormal - fi + --native-spring4-build-options) + NATIVE_SPRING4_BUILD_OPTIONS="$2" + shift 2 ;; - q) QUARKUS_VERSION=$OPTARG + --profiler) + if [[ "$2" =~ ^(none|jfr|flamegraph)$ ]]; then + PROFILER="$2" + else + echo "!! [ERROR] --profiler option must be one of (none, jfr, flamegraph)!!" + exit_abnormal + fi + shift 2 ;; - r) rt=($(IFS=','; echo $OPTARG)) + --quarkus-version) + QUARKUS_VERSION="$2" + shift 2 + ;; + + --runtimes) + rt=($(IFS=','; echo $2)) + + for item in "${rt[@]}"; do + if [[ ! "${ALLOWED_RUNTIMES[@]}" =~ "${item}" ]]; then + echo "!! [ERROR] --runtimes option must contain 1 or more of [${ALLOWED_RUNTIMES[@]}]!!" + exit_abnormal + fi + done - for item in "${rt[@]}"; do - if [[ ! "${ALLOWED_RUNTIMES[@]}" =~ "${item}" ]]; then - echo "!! [ERROR] -r option must contain 1 or more of [${ALLOWED_RUNTIMES[@]}]!!" - exit_abnormal - fi - done + RUNTIMES=${rt[@]} + shift 2 + ;; - RUNTIMES=${rt[@]} + --springboot3-version) + SPRING_BOOT3_VERSION="$2" + shift 2 ;; - s) SPRING_BOOT_VERSION=$OPTARG + --springboot4-version) + SPRING_BOOT4_VERSION="$2" + shift 2 ;; - t) ttr=($(IFS=','; echo $OPTARG)) + --tests) + ttr=($(IFS=','; echo $2)) + + for item in "${ttr[@]}"; do + if [[ ! "${ALLOWED_TESTS_TO_RUN[@]}" =~ "${item}" ]]; then + echo "!! [ERROR] --tests option must contain 1 or more of [${ALLOWED_TESTS_TO_RUN[@]}]!!" + exit_abnormal + fi + done - for item in "${ttr[@]}"; do - if [[ ! "${ALLOWED_TESTS_TO_RUN[@]}" =~ "${item}" ]]; then - echo "!! [ERROR] -t option must contain 1 or more of [${ALLOWED_TESTS_TO_RUN[@]}]!!" - exit_abnormal - fi - done + TESTS_TO_RUN=${ttr[@]} + shift 2 + ;; - TESTS_TO_RUN=${ttr[@]} + --user) + USER="$2" + shift 2 ;; - u) USER=$OPTARG + --jvm-memory) + JVM_MEMORY="$2" + shift 2 ;; - v) JVM_MEMORY=$OPTARG + --wait-time) + WAIT_TIME="$2" + shift 2 ;; - w) WAIT_TIME=$OPTARG + -*) + echo "!! [ERROR] Unknown option: $1" + exit_abnormal ;; - *) exit_abnormal + *) + echo "!! [ERROR] Unexpected argument: $1" + exit_abnormal ;; esac done diff --git a/springboot3/pom.xml b/springboot3/pom.xml index c552656..68a1b7c 100644 --- a/springboot3/pom.xml +++ b/springboot3/pom.xml @@ -85,9 +85,8 @@ enhance - true - true - true + false + false diff --git a/springboot4/.mvn/wrapper/maven-wrapper.properties b/springboot4/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/springboot4/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/springboot4/README.md b/springboot4/README.md new file mode 100644 index 0000000..58986d3 --- /dev/null +++ b/springboot4/README.md @@ -0,0 +1,20 @@ +## JVM Mode +To compile for JVM mode: +`./mvnw clean package` + +To run in JVM Mode: +`java -jar target/app.jar` + +## AOT on JVM mode +To compile for AOT mode: +`./mvnw clean compile spring-boot:process-aot package` + +To run in AOT mode: +`java -Dspring.aot.enabled=true -jar target/springboot4.jar` + +## Native Mode +To compile for native mode: +`./mvnw clean native:compile -Pnative` + +To run in native mode: +`target/springboot4` \ No newline at end of file diff --git a/springboot4/mvnw b/springboot4/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/springboot4/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/springboot4/mvnw.cmd b/springboot4/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/springboot4/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/springboot4/pom.xml b/springboot4/pom.xml new file mode 100644 index 0000000..b305d48 --- /dev/null +++ b/springboot4/pom.xml @@ -0,0 +1,129 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.0 + + + org.acme + springboot4 + 1.0 + springboot4 + Spring Boot 4 example + + 21 + 4.0.0 + 7.1.9.Final + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-actuator + + + org.postgresql + postgresql + runtime + + + org.hibernate.orm + hibernate-jcache + + + com.github.ben-manes.caffeine + caffeine + + + com.github.ben-manes.caffeine + jcache + + + org.springframework.boot + spring-boot-starter-validation-test + test + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-actuator-test + test + + + org.assertj + assertj-core + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-postgresql + test + + + + springboot4 + + + org.hibernate.orm + hibernate-maven-plugin + ${hibernate.version} + + + enhance + + enhance + + + + + + org.springframework.boot + spring-boot-starter-data-jpa + ${spring-boot.version} + + + + + org.graalvm.buildtools + native-maven-plugin + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/springboot4/src/main/java/org/acme/L2CacheConfiguration.java b/springboot4/src/main/java/org/acme/L2CacheConfiguration.java new file mode 100644 index 0000000..9f00f5a --- /dev/null +++ b/springboot4/src/main/java/org/acme/L2CacheConfiguration.java @@ -0,0 +1,59 @@ +package org.acme; + +import java.net.URI; + +import javax.cache.CacheManager; +import javax.cache.Caching; +import javax.cache.spi.CachingProvider; + +import org.springframework.boot.hibernate.autoconfigure.HibernatePropertiesCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.github.benmanes.caffeine.jcache.configuration.CaffeineConfiguration; +import com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider; + +@Configuration +public class L2CacheConfiguration { + @Bean + public CacheManager jCacheManager() { + CachingProvider provider = Caching.getCachingProvider(CaffeineCachingProvider.class.getName()); + CacheManager cacheManager = provider.getCacheManager(URI.create("caffeine://default"), getClass().getClassLoader()); + + // Create default cache configuration with store-by-reference + createCache(cacheManager, "default", createCaffeineConfig()); + + // Create cache for Store entity with expiration and size limits + createCache(cacheManager, "org.acme.domain.Store", + createCaffeineConfig()); + + // Create cache for StoreFruitPrice.store association with expiration and size limits + createCache(cacheManager, "org.acme.domain.StoreFruitPrice.store", + createCaffeineConfig()); + + return cacheManager; + } + + private void createCache(CacheManager cacheManager, String cacheName, CaffeineConfiguration config) { + if (cacheManager.getCache(cacheName) == null) { + cacheManager.createCache(cacheName, config); + } + } + + private CaffeineConfiguration createCaffeineConfig() { + var config = new CaffeineConfiguration<>(); + + // Use store-by-reference (not store-by-value) + config.setStoreByValue(false); + + return config; + } + + @Bean + public HibernatePropertiesCustomizer hibernatePropertiesCustomizer(CacheManager cacheManager) { + return hibernateProperties -> { + // Inject the programmatically created CacheManager into Hibernate + hibernateProperties.put("hibernate.javax.cache.cache_manager", cacheManager); + }; + } +} diff --git a/springboot4/src/main/java/org/acme/L2CacheRuntimeHints.java b/springboot4/src/main/java/org/acme/L2CacheRuntimeHints.java new file mode 100644 index 0000000..4ec521a --- /dev/null +++ b/springboot4/src/main/java/org/acme/L2CacheRuntimeHints.java @@ -0,0 +1,30 @@ +package org.acme; + +import org.hibernate.cache.jcache.internal.JCacheRegionFactory; + +import org.springframework.aot.hint.MemberCategory; +import org.springframework.aot.hint.RuntimeHints; +import org.springframework.aot.hint.RuntimeHintsRegistrar; + +import com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider; + +public class L2CacheRuntimeHints implements RuntimeHintsRegistrar { + @Override + public void registerHints(RuntimeHints hints, ClassLoader classLoader) { + var reflectionHints = hints.reflection(); + + // Register JCache region factory for Hibernate L2 cache + reflectionHints.registerType(JCacheRegionFactory.class, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS); + + // Register Caffeine JCache provider for GraalVM native image + reflectionHints.registerType(CaffeineCachingProvider.class, + MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_DECLARED_METHODS); + + // Note: With Caffeine store-by-reference, serialization is not needed. + // Cache stores references to objects directly in memory without serialization overhead. + // Caches are configured programmatically in L2CacheConfiguration, no config file needed. + } +} \ No newline at end of file diff --git a/springboot4/src/main/java/org/acme/SpringBoot4Application.java b/springboot4/src/main/java/org/acme/SpringBoot4Application.java new file mode 100644 index 0000000..0f695ae --- /dev/null +++ b/springboot4/src/main/java/org/acme/SpringBoot4Application.java @@ -0,0 +1,15 @@ +package org.acme; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ImportRuntimeHints; + +@SpringBootApplication +@ImportRuntimeHints(L2CacheRuntimeHints.class) +public class SpringBoot4Application { + + public static void main(String[] args) { + SpringApplication.run(SpringBoot4Application.class, args); + } + +} diff --git a/springboot4/src/main/java/org/acme/domain/Address.java b/springboot4/src/main/java/org/acme/domain/Address.java new file mode 100644 index 0000000..205be36 --- /dev/null +++ b/springboot4/src/main/java/org/acme/domain/Address.java @@ -0,0 +1,20 @@ +package org.acme.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.validation.constraints.NotBlank; + +@Embeddable +public record Address( + @Column(nullable = false) + @NotBlank(message = "Address is mandatory") + String address, + + @Column(nullable = false) + @NotBlank(message = "City is mandatory") + String city, + + @Column(nullable = false) + @NotBlank(message = "Country is mandatory") + String country +) {} diff --git a/springboot4/src/main/java/org/acme/domain/Fruit.java b/springboot4/src/main/java/org/acme/domain/Fruit.java new file mode 100644 index 0000000..37629b0 --- /dev/null +++ b/springboot4/src/main/java/org/acme/domain/Fruit.java @@ -0,0 +1,86 @@ +package org.acme.domain; + +import java.util.List; +import java.util.StringJoiner; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; + +import org.hibernate.annotations.NaturalId; + +@Entity +@Table(name = "fruits") +public class Fruit { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "fruits_seq") + @SequenceGenerator(name = "fruits_seq", sequenceName = "fruits_seq", allocationSize = 1) + private Long id; + + @Column(nullable = false, unique = true) + @NaturalId + @NotBlank(message = "Name is mandatory") + private String name; + private String description; + + @OneToMany(mappedBy = "fruit") + private List storePrices; + + public Fruit() { + } + + public Fruit(Long id, String name, String description) { + this.id = id; + this.name = name; + this.description = description; + } + + public Long getId() { + return this.id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return this.description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getStorePrices() { + return storePrices; + } + + public void setStorePrices(List storePrices) { + this.storePrices = storePrices; + } + + @Override + public String toString() { + return new StringJoiner(", ", Fruit.class.getSimpleName() + "[", "]") + .add("id=" + this.id) + .add("name='" + this.name + "'") + .add("description='" + this.description + "'") + .add("storePrices=" + this.storePrices) + .toString(); + } + +} diff --git a/springboot4/src/main/java/org/acme/domain/Store.java b/springboot4/src/main/java/org/acme/domain/Store.java new file mode 100644 index 0000000..75aad15 --- /dev/null +++ b/springboot4/src/main/java/org/acme/domain/Store.java @@ -0,0 +1,75 @@ +package org.acme.domain; + +import java.util.StringJoiner; + +import jakarta.persistence.Cacheable; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.SequenceGenerator; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotBlank; + +import org.hibernate.annotations.NaturalId; + +@Entity +@Table(name = "stores") +@Cacheable +public class Store { + @Id + @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "stores_seq") + @SequenceGenerator(name = "stores_seq", sequenceName = "stores_seq", allocationSize = 1) + private Long id; + + @Column(nullable = false, unique = true) + @NaturalId + @NotBlank(message = "Name is mandatory") + private String name; + + @Column(nullable = false) + @NotBlank(message = "Currency is mandatory") + private String currency; + + @Embedded + private Address address; + + public Store() {} + + public Store(Long id, String name, Address address, String currency) { + this.id = id; + this.name = name; + this.address = address; + this.currency = currency; + } + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public Address getAddress() { return address; } + public void setAddress(Address address) { this.address = address; } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + @Override + public String toString() { + return new StringJoiner(", ", Store.class.getSimpleName() + "[", "]") + .add("id=" + id) + .add("name='" + name + "'") + .add("address=" + address) + .add("currency='" + currency + "'") + .toString(); + } + +} diff --git a/springboot4/src/main/java/org/acme/domain/StoreFruitPrice.java b/springboot4/src/main/java/org/acme/domain/StoreFruitPrice.java new file mode 100644 index 0000000..eb53fa7 --- /dev/null +++ b/springboot4/src/main/java/org/acme/domain/StoreFruitPrice.java @@ -0,0 +1,78 @@ +package org.acme.domain; + +import java.math.BigDecimal; + +import jakarta.persistence.Column; +import jakarta.persistence.EmbeddedId; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; +import org.hibernate.annotations.Fetch; +import org.hibernate.annotations.FetchMode; + +@Entity +@Table(name = "store_fruit_prices") +public class StoreFruitPrice { + @EmbeddedId + @JsonIgnore + private StoreFruitPriceId id; + + @MapsId("storeId") + @ManyToOne(fetch = FetchType.EAGER, optional = false) + @JoinColumn(name = "store_id", nullable = false) + @Fetch(FetchMode.SELECT) + @Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) + private Store store; + + @MapsId("fruitId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "fruit_id", nullable = false) + @JsonIgnore + private Fruit fruit; + + @NotNull + @DecimalMin(value = "0.00", message = "Price must be >= 0") + @Digits(integer = 10, fraction = 2) + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal price; + + public StoreFruitPrice() {} + + public StoreFruitPrice(Store store, Fruit fruit, BigDecimal price) { + this.store = store; + this.fruit = fruit; + this.price = price; + this.id = new StoreFruitPriceId(store, fruit); + } + + public StoreFruitPriceId getId() { return id; } + public void setId(StoreFruitPriceId id) { this.id = id; } + + public Store getStore() { return store; } + public void setStore(Store store) { + this.store = store; + this.id = new StoreFruitPriceId((store != null) ? store.getId() : null, + (this.id != null) ? this.id.fruitId() : null); + } + + public Fruit getFruit() { return fruit; } + public void setFruit(Fruit fruit) { + this.fruit = fruit; + this.id = new StoreFruitPriceId((this.id != null) ? this.id.storeId() : null, + (fruit != null) ? fruit.getId() : null); + } + + public BigDecimal getPrice() { return price; } + public void setPrice(BigDecimal price) { this.price = price; } + +} diff --git a/springboot4/src/main/java/org/acme/domain/StoreFruitPriceId.java b/springboot4/src/main/java/org/acme/domain/StoreFruitPriceId.java new file mode 100644 index 0000000..0ee4b6b --- /dev/null +++ b/springboot4/src/main/java/org/acme/domain/StoreFruitPriceId.java @@ -0,0 +1,17 @@ +package org.acme.domain; + +import java.io.Serializable; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; + +@Embeddable +public record StoreFruitPriceId( + @Column(nullable = false) Long storeId, + @Column(nullable = false) Long fruitId +) implements Serializable { + + public StoreFruitPriceId(Store store, Fruit fruit) { + this((store != null) ? store.getId() : null, (fruit != null) ? fruit.getId() : null); + } +} diff --git a/springboot4/src/main/java/org/acme/repository/FruitRepository.java b/springboot4/src/main/java/org/acme/repository/FruitRepository.java new file mode 100644 index 0000000..24c0b1e --- /dev/null +++ b/springboot4/src/main/java/org/acme/repository/FruitRepository.java @@ -0,0 +1,11 @@ +package org.acme.repository; + +import java.util.Optional; + +import org.acme.domain.Fruit; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FruitRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/springboot4/src/main/java/org/acme/rest/FruitController.java b/springboot4/src/main/java/org/acme/rest/FruitController.java new file mode 100644 index 0000000..23db990 --- /dev/null +++ b/springboot4/src/main/java/org/acme/rest/FruitController.java @@ -0,0 +1,46 @@ +package org.acme.rest; + +import java.util.List; + +import jakarta.transaction.Transactional; +import jakarta.validation.Valid; + +import org.acme.domain.Fruit; +import org.acme.repository.FruitRepository; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/fruits") +public class FruitController { + private final FruitRepository fruitRepository; + + public FruitController(FruitRepository fruitRepository) { + this.fruitRepository = fruitRepository; + } + + @GetMapping(produces = MediaType.APPLICATION_JSON_VALUE) + public List getAll() { + return this.fruitRepository.findAll(); + } + + @GetMapping(path = "/{name}", produces = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity getFruit(@PathVariable String name) { + return this.fruitRepository.findByName(name) + .map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping(produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.APPLICATION_JSON_VALUE) + @Transactional + public Fruit addFruit(@Valid @RequestBody Fruit fruit) { + return this.fruitRepository.save(fruit); + } +} diff --git a/springboot4/src/main/resources/application.yml b/springboot4/src/main/resources/application.yml new file mode 100644 index 0000000..5f0942a --- /dev/null +++ b/springboot4/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + datasource: + username: fruits + password: fruits + url: jdbc:postgresql://localhost:5432/fruits + defer-datasource-initialization: true + jackson: + default-property-inclusion: non_empty + jpa: + properties: + hibernate: + cache: + # 1. Enable Hibernate's L2 Caching + use_second_level_cache: true + # 2. Configure the cache region factory (specifies the JCache/Caffeine provider) + region: + factory_class: jcache diff --git a/springboot4/src/main/resources/data.sql b/springboot4/src/main/resources/data.sql new file mode 100644 index 0000000..56e5c19 --- /dev/null +++ b/springboot4/src/main/resources/data.sql @@ -0,0 +1,61 @@ +-- Fruits +INSERT INTO fruits(id, name, description) VALUES (1, 'Apple', 'Hearty fruit'); +INSERT INTO fruits(id, name, description) VALUES (2, 'Pear', 'Juicy fruit'); +INSERT INTO fruits(id, name, description) VALUES (3, 'Banana', 'Tropical yellow fruit'); +INSERT INTO fruits(id, name, description) VALUES (4, 'Orange', 'Citrus fruit rich in vitamin C'); +INSERT INTO fruits(id, name, description) VALUES (5, 'Strawberry', 'Sweet red berry'); +INSERT INTO fruits(id, name, description) VALUES (6, 'Mango', 'Exotic tropical fruit'); +INSERT INTO fruits(id, name, description) VALUES (7, 'Grape', 'Small purple or green fruit'); +INSERT INTO fruits(id, name, description) VALUES (8, 'Pineapple', 'Large tropical fruit'); +INSERT INTO fruits(id, name, description) VALUES (9, 'Watermelon', 'Large refreshing summer fruit'); +INSERT INTO fruits(id, name, description) VALUES (10, 'Kiwi', 'Small fuzzy green fruit'); + +ALTER SEQUENCE fruits_seq RESTART WITH 11; + +-- Stores +INSERT INTO stores(id, name, address, city, country, currency) VALUES (1, 'Store 1', '123 Main St', 'Anytown', 'USA', 'USD'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (2, 'Store 2', '456 Main St', 'Paris', 'France', 'EUR'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (3, 'Store 3', '789 Oak Ave', 'London', 'UK', 'GBP'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (4, 'Store 4', '321 Cherry Ln', 'Tokyo', 'Japan', 'JPY'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (5, 'Store 5', '555 Maple Dr', 'Toronto', 'Canada', 'CAD'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (6, 'Store 6', '888 Pine St', 'Sydney', 'Australia', 'AUD'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (7, 'Store 7', '999 Elm Rd', 'Berlin', 'Germany', 'EUR'); +INSERT INTO stores(id, name, address, city, country, currency) VALUES (8, 'Store 8', '147 Birch Blvd', 'Mexico City', 'Mexico', 'MXN'); + +ALTER SEQUENCE stores_seq RESTART WITH 9; + +-- Prices +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (1, 1, 1.29); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (1, 2, 0.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (1, 3, 0.59); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (1, 4, 1.19); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (1, 5, 3.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (2, 1, 2.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (2, 2, 1.19); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (2, 3, 0.89); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (2, 4, 1.79); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (2, 6, 2.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (3, 1, 1.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (3, 2, 1.29); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (3, 5, 3.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (3, 7, 2.79); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (4, 1, 189.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (4, 3, 99.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (4, 4, 149.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (4, 8, 599.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (5, 1, 1.79); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (5, 2, 1.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (5, 5, 4.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (5, 9, 6.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (6, 1, 2.19); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (6, 6, 3.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (6, 8, 5.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (6, 10, 1.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (7, 2, 1.39); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (7, 4, 1.89); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (7, 7, 2.49); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (7, 9, 4.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (8, 1, 25.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (8, 3, 12.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (8, 6, 39.99); +INSERT INTO store_fruit_prices(store_id, fruit_id, price) VALUES (8, 8, 49.99); \ No newline at end of file diff --git a/springboot4/src/test/java/org/acme/ContainersConfig.java b/springboot4/src/test/java/org/acme/ContainersConfig.java new file mode 100644 index 0000000..a3e7e8e --- /dev/null +++ b/springboot4/src/test/java/org/acme/ContainersConfig.java @@ -0,0 +1,17 @@ +package org.acme; + +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; + +@TestConfiguration(proxyBeanMethods = false) +public class ContainersConfig { + @Bean + @ServiceConnection + public PostgreSQLContainer postgres() { + return new PostgreSQLContainer<>(DockerImageName.parse("postgres:17")); + } +} diff --git a/springboot4/src/test/java/org/acme/SpringBoot4ApplicationTests.java b/springboot4/src/test/java/org/acme/SpringBoot4ApplicationTests.java new file mode 100644 index 0000000..2fff9e4 --- /dev/null +++ b/springboot4/src/test/java/org/acme/SpringBoot4ApplicationTests.java @@ -0,0 +1,15 @@ +package org.acme; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@Import(ContainersConfig.class) +class SpringBoot4ApplicationTests { + @Test + void contextLoads() { + } + +} diff --git a/springboot4/src/test/java/org/acme/TestApplication.java b/springboot4/src/test/java/org/acme/TestApplication.java new file mode 100644 index 0000000..c92b888 --- /dev/null +++ b/springboot4/src/test/java/org/acme/TestApplication.java @@ -0,0 +1,12 @@ +package org.acme; + +import org.springframework.boot.SpringApplication; + +public class TestApplication { + public static void main(String[] args) { + SpringApplication + .from(SpringBoot4Application::main) + .with(ContainersConfig.class) + .run(args); + } +} diff --git a/springboot4/src/test/java/org/acme/repository/FruitRepositoryTests.java b/springboot4/src/test/java/org/acme/repository/FruitRepositoryTests.java new file mode 100644 index 0000000..877b01c --- /dev/null +++ b/springboot4/src/test/java/org/acme/repository/FruitRepositoryTests.java @@ -0,0 +1,40 @@ +package org.acme.repository; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; + +import jakarta.transaction.Transactional; + +import org.acme.ContainersConfig; +import org.acme.domain.Fruit; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@Transactional +@Import(ContainersConfig.class) +class FruitRepositoryTests { + @Autowired + FruitRepository fruitRepository; + + @Test + public void findByName() { + this.fruitRepository.save(new Fruit(null, "Grapefruit", "Summer fruit")); + + Optional fruit = this.fruitRepository.findByName("Grapefruit"); + assertThat(fruit) + .isNotNull() + .isPresent() + .get() + .extracting(Fruit::getName, Fruit::getDescription) + .containsExactly("Grapefruit", "Summer fruit"); + + assertThat(fruit.get().getId()) + .isNotNull() + .isGreaterThan(2L); + } +} diff --git a/springboot4/src/test/java/org/acme/rest/FruitControllerTests.java b/springboot4/src/test/java/org/acme/rest/FruitControllerTests.java new file mode 100644 index 0000000..4d65e8e --- /dev/null +++ b/springboot4/src/test/java/org/acme/rest/FruitControllerTests.java @@ -0,0 +1,133 @@ +package org.acme.rest; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import org.acme.ContainersConfig; +import org.acme.domain.Address; +import org.acme.domain.Fruit; +import org.acme.domain.Store; +import org.acme.domain.StoreFruitPrice; +import org.acme.repository.FruitRepository; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(ContainersConfig.class) +class FruitControllerTests { + @Autowired + MockMvc mockMvc; + + @MockitoBean + FruitRepository fruitRepository; + + private static Fruit createFruit() { + var price = BigDecimal.valueOf(1.29); + var store = new Store(1L, "Some Store", new Address("123 Some St", "Some City", "USA"), "USD"); + var fruit = new Fruit(1L, "Apple", "Hearty Fruit"); + fruit.setStorePrices(List.of(new StoreFruitPrice(store, fruit, price))); + + return fruit; + } + + @Test + void getAll() throws Exception { + var fruit = createFruit(); + var fruitStorePrice = fruit.getStorePrices().getFirst(); + var store = fruitStorePrice.getStore(); + + Mockito.when(this.fruitRepository.findAll()) + .thenReturn(List.of(fruit)); + + this.mockMvc.perform(get("/fruits")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.size()").value(1)) + .andExpect(jsonPath("[0].id").value(1)) + .andExpect(jsonPath("[0].name").value("Apple")) + .andExpect(jsonPath("[0].description").value("Hearty Fruit")) + .andExpect(jsonPath("[0].description").value("Hearty Fruit")) + .andExpect(jsonPath("[0].storePrices[0].price").value(fruitStorePrice.getPrice().floatValue())) + .andExpect(jsonPath("[0].storePrices[0].store.name").value(store.getName())) + .andExpect(jsonPath("[0].storePrices[0].store.address.address").value(store.getAddress().address())) + .andExpect(jsonPath("[0].storePrices[0].store.address.city").value(store.getAddress().city())) + .andExpect(jsonPath("[0].storePrices[0].store.address.country").value(store.getAddress().country())) + .andExpect(jsonPath("[0].storePrices[0].store.currency").value(store.getCurrency())); + + Mockito.verify(this.fruitRepository).findAll(); + Mockito.verifyNoMoreInteractions(this.fruitRepository); + } + + @Test + void getFruitFound() throws Exception { + var fruit = createFruit(); + var fruitStorePrice = fruit.getStorePrices().getFirst(); + var store = fruitStorePrice.getStore(); + + Mockito.when(this.fruitRepository.findByName("Apple")) + .thenReturn(Optional.of(fruit)); + + this.mockMvc.perform(get("/fruits/Apple")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("id").value(1)) + .andExpect(jsonPath("name").value("Apple")) + .andExpect(jsonPath("description").value("Hearty Fruit")) + .andExpect(jsonPath("storePrices[0].price").value(fruitStorePrice.getPrice().floatValue())) + .andExpect(jsonPath("storePrices[0].store.name").value(store.getName())) + .andExpect(jsonPath("storePrices[0].store.address.address").value(store.getAddress().address())) + .andExpect(jsonPath("storePrices[0].store.address.city").value(store.getAddress().city())) + .andExpect(jsonPath("storePrices[0].store.address.country").value(store.getAddress().country())) + .andExpect(jsonPath("storePrices[0].store.currency").value(store.getCurrency())); + + Mockito.verify(this.fruitRepository).findByName("Apple"); + Mockito.verifyNoMoreInteractions(this.fruitRepository); + } + + @Test + void getFruitNotFound() throws Exception { + Mockito.when(this.fruitRepository.findByName("Apple")) + .thenReturn(Optional.empty()); + + this.mockMvc.perform(get("/fruits/Apple")) + .andExpect(status().isNotFound()); + + Mockito.verify(this.fruitRepository).findByName("Apple"); + Mockito.verifyNoMoreInteractions(this.fruitRepository); + } + + @Test + void addFruit() throws Exception { + Mockito.when(this.fruitRepository.save(Mockito.any(Fruit.class))) + .thenReturn(new Fruit(1L, "Grapefruit", "Summer fruit")); + + this.mockMvc.perform( + post("/fruits") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"Grapefruit\",\"description\":\"Summer fruit\"}") + ) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("name").value("Grapefruit")) + .andExpect(jsonPath("description").value("Summer fruit")); + + Mockito.verify(this.fruitRepository).save(Mockito.any(Fruit.class)); + Mockito.verifyNoMoreInteractions(this.fruitRepository); + } +} diff --git a/springboot4/src/test/resources/application.yml b/springboot4/src/test/resources/application.yml new file mode 100644 index 0000000..ee549be --- /dev/null +++ b/springboot4/src/test/resources/application.yml @@ -0,0 +1,14 @@ +spring: + datasource: + username: fruits + password: fruits + url: jdbc:postgresql://localhost:5432/fruits + jpa: + hibernate: + ddl-auto: create-drop + defer-datasource-initialization: true + jackson: + default-property-inclusion: non_empty + sql: + init: + mode: always \ No newline at end of file