From 539ab211fb5bb727a5f3607079d9c05fbdc8c933 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Thu, 28 Sep 2023 03:31:56 +0900 Subject: [PATCH 01/26] =?UTF-8?q?refs=20#1=20Gradle=E3=83=97=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 + .gitignore | 39 ++++ README.md | 52 +++++ build.gradle.kts | 72 +++++++ gradle.properties | 5 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 58910 bytes gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 ++++++++++++++++++ gradlew.bat | 104 ++++++++++ settings.gradle | 11 ++ src/main/docker/Dockerfile.jvm | 97 +++++++++ src/main/docker/Dockerfile.legacy-jar | 93 +++++++++ src/main/docker/Dockerfile.native | 27 +++ src/main/docker/Dockerfile.native-micro | 30 +++ .../controller/HealthCheckController.kt | 13 ++ src/main/resources/application.yaml | 0 16 files changed, 738 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle.properties 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 settings.gradle create mode 100644 src/main/docker/Dockerfile.jvm create mode 100644 src/main/docker/Dockerfile.legacy-jar create mode 100644 src/main/docker/Dockerfile.native create mode 100644 src/main/docker/Dockerfile.native-micro create mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt create mode 100644 src/main/resources/application.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4361d2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!build/*-runner +!build/*-runner.jar +!build/lib/* +!build/quarkus-app/* \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..216783d --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Gradle +.gradle/ +build/ + +# Eclipse +.project +.classpath +.settings/ +bin/ + +# IntelliJ +.idea +*.ipr +*.iml +*.iws + +# NetBeans +nb-configuration.xml + +# Visual Studio Code +.vscode +.factorypath + +# OSX +.DS_Store + +# Vim +*.swp +*.swo + +# patch +*.orig +*.rej + +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b89845 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# gsync + +This project uses Quarkus, the Supersonic Subatomic Java Framework. + +If you want to learn more about Quarkus, please visit its website: https://quarkus.io/ . + +## Running the application in dev mode + +You can run your application in dev mode that enables live coding using: +```shell script +./gradlew quarkusDev +``` + +> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. + +## Packaging and running the application + +The application can be packaged using: +```shell script +./gradlew build +``` +It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `build/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: +```shell script +./gradlew build -Dquarkus.package.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar build/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: +```shell script +./gradlew build -Dquarkus.package.type=native +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: +```shell script +./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./build/gsync-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult https://quarkus.io/guides/gradle-tooling. + +## Related Guides + +- Kotlin ([guide](https://quarkus.io/guides/kotlin)): Write your services in Kotlin diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..cf0f905 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,72 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.9.10" + kotlin("plugin.allopen") version "1.9.10" + + id("io.quarkus") + id("com.github.ben-manes.versions") version "0.47.0" + + application + java + groovy + eclipse + idea +} + +buildscript { + dependencies { + classpath("com.github.ben-manes:gradle-versions-plugin:0.47.0") + } +} + +repositories { + mavenCentral() + mavenLocal() + gradlePluginPortal() +} + +group = "net.averak.gsync" +version = "1.0.0-SNAPSHOT" + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +dependencies { + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-kotlin") + implementation("io.quarkus:quarkus-resteasy-reactive-jackson") + implementation("io.quarkus:quarkus-hibernate-orm") + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-resteasy-reactive") + implementation("io.quarkus:quarkus-config-yaml") + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + + // test + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.github.dvgaba:easy-random-core:6.2.0") + testImplementation("org.apache.groovy:groovy") + testImplementation("org.apache.groovy:groovy-sql") + testImplementation("org.spockframework:spock-core:2.4-M1-groovy-4.0") + testImplementation("org.spockframework:spock-junit4:2.4-M1-groovy-4.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType { + systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") +} +allOpen { + annotation("jakarta.ws.rs.Path") + annotation("jakarta.enterprise.context.ApplicationScoped") + annotation("io.quarkus.test.junit.QuarkusTest") +} + +tasks.withType { + kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() + kotlinOptions.javaParameters = true +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..428c0b9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +quarkusPluginId=io.quarkus +quarkusPluginVersion=3.4.1 +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.4.1 \ 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..62d4c053550b91381bbd28b1afc82d634bf73a8a GIT binary patch literal 58910 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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 0000000..db9a6b8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..fbd7c51 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a9f778a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,104 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto 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/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b70661f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,11 @@ +pluginManagement { + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id "${quarkusPluginId}" version "${quarkusPluginVersion}" + } +} +rootProject.name="gsync" diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..72997c5 --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,97 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/gsync-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/gsync-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/gsync-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-17:1.16 + +ENV LANGUAGE='en_US:en' + + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] + diff --git a/src/main/docker/Dockerfile.legacy-jar b/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 0000000..c3b46dc --- /dev/null +++ b/src/main/docker/Dockerfile.legacy-jar @@ -0,0 +1,93 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=legacy-jar +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/gsync-legacy-jar . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/gsync-legacy-jar +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/gsync-legacy-jar +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +FROM registry.access.redhat.com/ubi8/openjdk-17:1.16 + +ENV LANGUAGE='en_US:en' + + +COPY build/lib/* /deployments/lib/ +COPY build/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..1cb8015 --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,27 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/gsync . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/gsync +# +### +FROM registry.access.redhat.com/ubi8/ubi-minimal:8.8 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/docker/Dockerfile.native-micro b/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..1204fe4 --- /dev/null +++ b/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,30 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.type=native +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/gsync . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/gsync +# +### +FROM quay.io/quarkus/quarkus-micro-image:2.0 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt new file mode 100644 index 0000000..bafc8ae --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt @@ -0,0 +1,13 @@ +package net.averak.gsync.adapter.handler.controller + +import jakarta.ws.rs.GET +import jakarta.ws.rs.Path + +@Path("/api/health") +class HealthCheckController { + + @GET + fun health() { + } + +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..e69de29 From 114f7ebc3b369a92c55132ffb2c0bb7703502780 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 30 Sep 2023 00:51:01 +0900 Subject: [PATCH 02/26] =?UTF-8?q?refs=20#1=20=E3=83=98=E3=83=AB=E3=82=B9?= =?UTF-8?q?=E3=83=81=E3=82=A7=E3=83=83=E3=82=AFAPI=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 49 +++++++++++-------- .../controller/HealthCheckController.kt | 4 +- .../net/averak/gsync/AbstractSpec.groovy | 8 +++ .../groovy/net/averak/gsync/TestConfig.groovy | 7 +++ .../net/averak/gsync/testutils/JsonUtils.kt | 31 ++++++++++++ 5 files changed, 78 insertions(+), 21 deletions(-) create mode 100644 src/test/groovy/net/averak/gsync/AbstractSpec.groovy create mode 100644 src/test/groovy/net/averak/gsync/TestConfig.groovy create mode 100644 src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt diff --git a/README.md b/README.md index 5b89845..4c40e71 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,34 @@ # gsync -This project uses Quarkus, the Supersonic Subatomic Java Framework. +![version](https://img.shields.io/badge/version-1.0.0--SNAPSHOT-blue.svg) -If you want to learn more about Quarkus, please visit its website: https://quarkus.io/ . +This is a multi-tenancy game server for MO games. + +This component provides only reusable features that can be used across various games, and individual logic cannot be embedded. + +## Features + +* Player authentication / authorization +* Friend +* Match making +* Realtime messaging +* And more + +## Develop + +### Environments + +* Java OpenJDK 17 +* Kotlin 1.9 +* Quarkus 3.4 +* TiDB 7.1 ## Running the application in dev mode You can run your application in dev mode that enables live coding using: + ```shell script +docker compose up -d ./gradlew quarkusDev ``` @@ -16,37 +37,25 @@ You can run your application in dev mode that enables live coding using: ## Packaging and running the application The application can be packaged using: + ```shell script ./gradlew build ``` + It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. -Be aware that it’s not an _über-jar_ as the dependencies are copied into the `build/quarkus-app/lib/` directory. The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. -If you want to build an _über-jar_, execute the following command: -```shell script -./gradlew build -Dquarkus.package.type=uber-jar -``` - -The application, packaged as an _über-jar_, is now runnable using `java -jar build/*-runner.jar`. - ## Creating a native executable -You can create a native executable using: +You can create a native executable using: + ```shell script ./gradlew build -Dquarkus.package.type=native ``` -Or, if you don't have GraalVM installed, you can run the native executable build in a container using: +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + ```shell script ./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true ``` - -You can then execute your native executable with: `./build/gsync-1.0.0-SNAPSHOT-runner` - -If you want to learn more about building native executables, please consult https://quarkus.io/guides/gradle-tooling. - -## Related Guides - -- Kotlin ([guide](https://quarkus.io/guides/kotlin)): Write your services in Kotlin diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt index bafc8ae..5007b7c 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt @@ -2,12 +2,14 @@ package net.averak.gsync.adapter.handler.controller import jakarta.ws.rs.GET import jakarta.ws.rs.Path +import org.jboss.resteasy.reactive.RestResponse @Path("/api/health") class HealthCheckController { @GET - fun health() { + fun health(): RestResponse { + return RestResponse.ok() } } diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy new file mode 100644 index 0000000..93fe2ae --- /dev/null +++ b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy @@ -0,0 +1,8 @@ +package net.averak.gsync + +import io.quarkus.test.junit.QuarkusTest +import spock.lang.Specification + +@QuarkusTest +abstract class AbstractSpec extends Specification { +} diff --git a/src/test/groovy/net/averak/gsync/TestConfig.groovy b/src/test/groovy/net/averak/gsync/TestConfig.groovy new file mode 100644 index 0000000..ecfdee3 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/TestConfig.groovy @@ -0,0 +1,7 @@ +package net.averak.gsync + +import jakarta.enterprise.context.ApplicationScoped + +@ApplicationScoped +class TestConfig { +} diff --git a/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt b/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt new file mode 100644 index 0000000..d0c1cbd --- /dev/null +++ b/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt @@ -0,0 +1,31 @@ +package net.averak.gsync.testutils + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule + +class JsonUtils { + + companion object { + + private val objectMapper = ObjectMapper() + .registerModule(JavaTimeModule()) + .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) + + @JvmStatic + fun toJson(value: Any?): String { + return if (value == null) { + "{}" + } else { + objectMapper.writeValueAsString(value) + } + } + + @JvmStatic + fun fromJson(json: String, clazz: Class): T { + return objectMapper.readValue(json, clazz) + } + + } + +} \ No newline at end of file From 3b8be132607f36f2b9ed1005452b9a01474a6f0b Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 30 Sep 2023 01:23:40 +0900 Subject: [PATCH 03/26] =?UTF-8?q?refs=20#3=20GitHub=20Actions=E3=82=92?= =?UTF-8?q?=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++++ README.md | 7 ++--- 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d83afa7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +name: CI + +on: + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: corretto + java-version: 17 + cache: gradle + + - name: backend test + run: | + ./gradlew test -x build + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: corretto + java-version: 17 + cache: gradle + + - name: backend lint + run: | + ./gradlew spotlessCheck + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-java@v3 + with: + distribution: corretto + java-version: 17 + cache: gradle + + - name: backend build + run: | + ./gradlew build \ No newline at end of file diff --git a/README.md b/README.md index 4c40e71..e8941ee 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # gsync +![CI](https://github.com/averak/gsync/workflows/CI/badge.svg) ![version](https://img.shields.io/badge/version-1.0.0--SNAPSHOT-blue.svg) This is a multi-tenancy game server for MO games. @@ -23,7 +24,7 @@ This component provides only reusable features that can be used across various g * Quarkus 3.4 * TiDB 7.1 -## Running the application in dev mode +### Running the application in dev mode You can run your application in dev mode that enables live coding using: @@ -34,7 +35,7 @@ docker compose up -d > **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. -## Packaging and running the application +### Packaging and running the application The application can be packaged using: @@ -46,7 +47,7 @@ It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. -## Creating a native executable +### Creating a native executable You can create a native executable using: From ed72153b1c8431a6cbf8dd4995997b0c733c200a Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 30 Sep 2023 01:29:43 +0900 Subject: [PATCH 04/26] =?UTF-8?q?refs=20#3=20Spotless=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 32 ++++++++++++++----- build.gradle.kts | 12 ++++++- .../controller/HealthCheckController.kt | 1 - .../net/averak/gsync/testutils/JsonUtils.kt | 4 +-- 4 files changed, 36 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index e8941ee..f26cc04 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,9 @@ This component provides only reusable features that can be used across various g ### Running the application in dev mode -You can run your application in dev mode that enables live coding using: +You can run your application in dev mode that enables live coding. -```shell script +```shell docker compose up -d ./gradlew quarkusDev ``` @@ -37,9 +37,9 @@ docker compose up -d ### Packaging and running the application -The application can be packaged using: +The application can be packaged. -```shell script +```shell ./gradlew build ``` @@ -49,14 +49,30 @@ The application is now runnable using `java -jar build/quarkus-app/quarkus-run.j ### Creating a native executable -You can create a native executable using: +You can create a native executable. -```shell script +```shell ./gradlew build -Dquarkus.package.type=native ``` -Or, if you don't have GraalVM installed, you can run the native executable build in a container using: +Or, if you don't have GraalVM installed, you can run the native executable build in a container. -```shell script +```shell ./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true ``` + +### Check Dependency updates + +[Gradle Versions Plugin](https://github.com/ben-manes/gradle-versions-plugin) checks outdated dependencies. + +```shell +$ ./gradlew dependencyUpdates -Drevision=release +``` + +### Run code formatter + +This codebase is formatted by [ktlint](https://github.com/pinterest/ktlint). + +```shell +./gradlew spotlessApply +``` diff --git a/build.gradle.kts b/build.gradle.kts index cf0f905..db55330 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,6 +6,7 @@ plugins { id("io.quarkus") id("com.github.ben-manes.versions") version "0.47.0" + id("com.diffplug.spotless") version "6.21.0" application java @@ -69,4 +70,13 @@ allOpen { tasks.withType { kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() kotlinOptions.javaParameters = true -} \ No newline at end of file +} + +spotless { + kotlin { + ktlint() + trimTrailingWhitespace() + indentWithSpaces() + endWithNewline() + } +} diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt index 5007b7c..8a531cd 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt @@ -11,5 +11,4 @@ class HealthCheckController { fun health(): RestResponse { return RestResponse.ok() } - } diff --git a/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt b/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt index d0c1cbd..8df04d5 100644 --- a/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt +++ b/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt @@ -25,7 +25,5 @@ class JsonUtils { fun fromJson(json: String, clazz: Class): T { return objectMapper.readValue(json, clazz) } - } - -} \ No newline at end of file +} From 14088660d0bd484ba352f1d4bfde7af1166abc93 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sun, 15 Oct 2023 20:40:59 +0900 Subject: [PATCH 05/26] =?UTF-8?q?refs=20#2=20Spanner=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 +++- Makefile | 27 ++++++++++++ README.md | 8 ++-- build.gradle.kts | 16 ++++++++ compose.yaml | 6 +++ docker/spanner/Dockerfile | 1 + .../gsync/infrastructure/json/JsonUtils.kt | 41 +++++++++++++++++++ .../infrastructure/spanner/SpannerClient.kt | 9 ++++ src/main/resources/application.yaml | 16 ++++++++ .../controller/AbstractController_IT.groovy | 6 +++ .../net/averak/gsync/testutils/JsonUtils.kt | 29 ------------- src/test/resources/application-test.yaml | 0 12 files changed, 133 insertions(+), 35 deletions(-) create mode 100644 Makefile create mode 100644 compose.yaml create mode 100644 docker/spanner/Dockerfile create mode 100644 src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt create mode 100644 src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt create mode 100644 src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy delete mode 100644 src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt create mode 100644 src/test/resources/application-test.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d83afa7..159d14e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,15 @@ jobs: distribution: corretto java-version: 17 cache: gradle + - uses: google-github-actions/setup-gcloud@v1 + + - name: start database + run: | + make init_spanner_emulator - name: backend test run: | - ./gradlew test -x build + make test lint: runs-on: ubuntu-latest @@ -39,7 +44,7 @@ jobs: - name: backend lint run: | - ./gradlew spotlessCheck + make lint build: runs-on: ubuntu-latest diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7bcbce --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +GOOGLE_CLOUD_PROJECT_ID="gsync" + +.PHONY: init_spanner_emulator +init_spanner_emulator: + docker-compose up -d + gcloud config configurations create emulator || true + gcloud config set auth/disable_credentials true + gcloud config set project ${GOOGLE_CLOUD_PROJECT_ID} + gcloud config set api_endpoint_overrides/spanner http://localhost:9020/ + gcloud spanner instances create gsync --config=emulator-config --description="gsync" --nodes=1 + gcloud spanner databases create gsync --instance=gsync + +.PHONY: test +test: + ./gradlew test + +.PHONY: lint +lint: + ./gradlew spotlessCheck + +.PHONY: format +format: + ./gradlew spotlessApply + +.PHONY: check_dependencies +check_dependencies: + ./gradlew dependencyUpdates -Drevision=release diff --git a/README.md b/README.md index f26cc04..c985f5b 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ This component provides only reusable features that can be used across various g * Java OpenJDK 17 * Kotlin 1.9 * Quarkus 3.4 -* TiDB 7.1 +* Cloud Spanner ### Running the application in dev mode You can run your application in dev mode that enables live coding. ```shell -docker compose up -d +make init_spanner_emulator ./gradlew quarkusDev ``` @@ -66,7 +66,7 @@ Or, if you don't have GraalVM installed, you can run the native executable build [Gradle Versions Plugin](https://github.com/ben-manes/gradle-versions-plugin) checks outdated dependencies. ```shell -$ ./gradlew dependencyUpdates -Drevision=release +make check_dependencies ``` ### Run code formatter @@ -74,5 +74,5 @@ $ ./gradlew dependencyUpdates -Drevision=release This codebase is formatted by [ktlint](https://github.com/pinterest/ktlint). ```shell -./gradlew spotlessApply +make format ``` diff --git a/build.gradle.kts b/build.gradle.kts index db55330..bbd4e07 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,7 @@ val quarkusPlatformArtifactId: String by project val quarkusPlatformVersion: String by project dependencies { + // Quarkus implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-resteasy-reactive-jackson") @@ -44,6 +45,21 @@ dependencies { implementation("io.quarkus:quarkus-config-yaml") implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + // GCP + // implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-parent:2.5.0") +// implementation("com.google.cloud:google-cloud-spanner:6.51.0") + implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-spanner:2.5.0") { + modules { + module("com.google.guava:listenablefuture") { + replacedBy("com.google.guava:guava", "listenablefuture is part of guava") + } + } + } + + // utils + implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") + implementation("com.google.guava:guava:32.1.3-jre") + // test testImplementation("io.quarkus:quarkus-junit5") testImplementation("io.github.dvgaba:easy-random-core:6.2.0") diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..8c70dde --- /dev/null +++ b/compose.yaml @@ -0,0 +1,6 @@ +services: + spanner: + build: ./docker/spanner + ports: + - "9010:9010" + - "9020:9020" diff --git a/docker/spanner/Dockerfile b/docker/spanner/Dockerfile new file mode 100644 index 0000000..64c44f5 --- /dev/null +++ b/docker/spanner/Dockerfile @@ -0,0 +1 @@ +FROM gcr.io/cloud-spanner-emulator/emulator \ No newline at end of file diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt new file mode 100644 index 0000000..108f261 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt @@ -0,0 +1,41 @@ +package net.averak.gsync.infrastructure.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinFeature +import com.fasterxml.jackson.module.kotlin.KotlinModule + +class JsonUtils { + + companion object { + + private val objectMapper = ObjectMapper() + .registerModule( + KotlinModule.Builder() + .withReflectionCacheSize(512) + .configure(KotlinFeature.NullToEmptyCollection, false) + .configure(KotlinFeature.NullToEmptyMap, false) + .configure(KotlinFeature.NullIsSameAsDefault, false) + .configure(KotlinFeature.SingletonSupport, false) + .configure(KotlinFeature.StrictNullChecks, false) + .build(), + ) + .registerModule(JavaTimeModule()) + .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) + + @JvmStatic + fun encode(value: Any?): String { + return if (value == null) { + "{}" + } else { + objectMapper.writeValueAsString(value) + } + } + + @JvmStatic + fun decode(json: String, clazz: Class): T { + return objectMapper.readValue(json, clazz) + } + } +} diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt b/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt new file mode 100644 index 0000000..cf63b5e --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt @@ -0,0 +1,9 @@ +package net.averak.gsync.infrastructure.spanner + +import com.google.cloud.spanner.Spanner +import jakarta.inject.Singleton + +@Singleton +class SpannerClient( + private val spanner: Spanner, +) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e69de29..ff7acd7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -0,0 +1,16 @@ +quarkus: + application: + name: "gsync" + version: "1.0.0-SNAPSHOT" + # datasource: + # db-kind: spanner + # jdbc: + # url: jdbc:cloudspanner:/projects/${quarkus.google.cloud.project-id}/instances/${quarkus.google.cloud.instance-id}/databases/${quarkus.google.cloud.database-id} + # driver: com.google.cloud.spanner.jdbc.JdbcDriver + google: + cloud: + project-id: ${GOOGLE_CLOUD_PROJECT_ID:gsync} + instance-id: ${GOOGLE_CLOUD_SPANNER_INSTANCE_ID:gsync} + database-id: ${GOOGLE_CLOUD_SPANNER_DATABASE_NAME:gsync} + spanner: + emulator-host: http://localhost:9020 diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy new file mode 100644 index 0000000..e97bce7 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy @@ -0,0 +1,6 @@ +package net.averak.gsync.adapter.handler.controller + +import net.averak.gsync.AbstractSpec + +class AbstractController_IT extends AbstractSpec { +} diff --git a/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt b/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt deleted file mode 100644 index 8df04d5..0000000 --- a/src/test/kotlin/net/averak/gsync/testutils/JsonUtils.kt +++ /dev/null @@ -1,29 +0,0 @@ -package net.averak.gsync.testutils - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule - -class JsonUtils { - - companion object { - - private val objectMapper = ObjectMapper() - .registerModule(JavaTimeModule()) - .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) - - @JvmStatic - fun toJson(value: Any?): String { - return if (value == null) { - "{}" - } else { - objectMapper.writeValueAsString(value) - } - } - - @JvmStatic - fun fromJson(json: String, clazz: Class): T { - return objectMapper.readValue(json, clazz) - } - } -} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..e69de29 From 4ab34f9d8c7ce777b790c4189000aa97c89304e4 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 28 Oct 2023 14:28:48 +0900 Subject: [PATCH 06/26] =?UTF-8?q?refs=20#9=20SonarCloud=E3=81=AB=E3=82=AB?= =?UTF-8?q?=E3=83=90=E3=83=AC=E3=83=83=E3=82=B8=E3=83=AC=E3=83=9D=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=BB=A2=E9=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 25 +++++++++++++++------ Makefile | 20 +++++++---------- build.gradle.kts | 48 +++++++++++++++++++++++++++++++--------- 3 files changed, 63 insertions(+), 30 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 159d14e..a1c022b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,20 +16,31 @@ jobs: steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - uses: actions/setup-java@v3 with: distribution: corretto java-version: 17 cache: gradle - - uses: google-github-actions/setup-gcloud@v1 - - name: start database - run: | - make init_spanner_emulator + - name: cache sonar packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar - name: backend test run: | - make test + ./gradlew test jacocoTestReport + + - name: backend analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + run: | + ./gradlew sonar lint: runs-on: ubuntu-latest @@ -44,7 +55,7 @@ jobs: - name: backend lint run: | - make lint + ./gradlew spotlessCheck build: runs-on: ubuntu-latest @@ -59,4 +70,4 @@ jobs: - name: backend build run: | - ./gradlew build \ No newline at end of file + ./gradlew build -x test \ No newline at end of file diff --git a/Makefile b/Makefile index a7bcbce..4a508ca 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,10 @@ -GOOGLE_CLOUD_PROJECT_ID="gsync" - -.PHONY: init_spanner_emulator -init_spanner_emulator: - docker-compose up -d - gcloud config configurations create emulator || true - gcloud config set auth/disable_credentials true - gcloud config set project ${GOOGLE_CLOUD_PROJECT_ID} - gcloud config set api_endpoint_overrides/spanner http://localhost:9020/ - gcloud spanner instances create gsync --config=emulator-config --description="gsync" --nodes=1 - gcloud spanner databases create gsync --instance=gsync +.PHONY: build +build: + ./gradlew build -x test .PHONY: test test: - ./gradlew test + ./gradlew test jacocoTestReport .PHONY: lint lint: @@ -25,3 +17,7 @@ format: .PHONY: check_dependencies check_dependencies: ./gradlew dependencyUpdates -Drevision=release + +.PHONY: update_dependencies +update_dependencies: + ./gradlew versionCatalogUpdate diff --git a/build.gradle.kts b/build.gradle.kts index bbd4e07..571af7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { kotlin("jvm") version "1.9.10" kotlin("plugin.allopen") version "1.9.10" @@ -7,10 +5,12 @@ plugins { id("io.quarkus") id("com.github.ben-manes.versions") version "0.47.0" id("com.diffplug.spotless") version "6.21.0" + id("org.sonarqube") version "4.4.1.3373" application java groovy + jacoco eclipse idea } @@ -36,7 +36,7 @@ val quarkusPlatformVersion: String by project dependencies { // Quarkus - implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation(enforcedPlatform("$quarkusPlatformGroupId:$quarkusPlatformArtifactId:$quarkusPlatformVersion")) implementation("io.quarkus:quarkus-kotlin") implementation("io.quarkus:quarkus-resteasy-reactive-jackson") implementation("io.quarkus:quarkus-hibernate-orm") @@ -47,7 +47,7 @@ dependencies { // GCP // implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-parent:2.5.0") -// implementation("com.google.cloud:google-cloud-spanner:6.51.0") + // implementation("com.google.cloud:google-cloud-spanner:6.51.0") implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-spanner:2.5.0") { modules { module("com.google.guava:listenablefuture") { @@ -74,25 +74,51 @@ java { targetCompatibility = JavaVersion.VERSION_17 } -tasks.withType { - systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") +kotlin { + jvmToolchain(17) } + allOpen { annotation("jakarta.ws.rs.Path") annotation("jakarta.enterprise.context.ApplicationScoped") annotation("io.quarkus.test.junit.QuarkusTest") } -tasks.withType { - kotlinOptions.jvmTarget = JavaVersion.VERSION_17.toString() - kotlinOptions.javaParameters = true -} - spotless { kotlin { + targetExclude("build/**/*.kt") ktlint() trimTrailingWhitespace() indentWithSpaces() endWithNewline() } } + +sonar { + properties { + property("sonar.projectKey", "averak_gsync") + property("sonar.organization", "averak") + property("sonar.host.url", "https://sonarcloud.io") + } +} + +tasks { + test { + useJUnitPlatform() + } + + jar { + enabled = false + } + + javadoc { + (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:none", true) + } + + jacocoTestReport { + reports { + xml.required = true + csv.required = true + } + } +} From 8c4c647976dea5038aae0ef87f4c9167c00ea98a Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 28 Oct 2023 14:46:52 +0900 Subject: [PATCH 07/26] =?UTF-8?q?refs=20#9=20Versions=20Catalog=E3=82=92?= =?UTF-8?q?=E5=B0=8E=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 8 +++ README.md | 25 ++++---- build.gradle.kts | 63 +++++++++---------- gradle.properties | 5 -- gradle/libs.versions.toml | 33 ++++++++++ settings.gradle | 12 +--- .../controller/HealthCheckController.kt | 3 +- .../gsync/infrastructure/json/JsonUtils.kt | 34 +++++----- 8 files changed, 101 insertions(+), 82 deletions(-) delete mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml diff --git a/Makefile b/Makefile index 4a508ca..dab2698 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,14 @@ build: ./gradlew build -x test +.PHONY: build_native +build_native: + ./gradlew build -x test -Dquarkus.package.type=native -Dquarkus.native.container-build=true + +.PHONY: run_application +run_application: + ./gradlew quarkusDev + .PHONY: test test: ./gradlew test jacocoTestReport diff --git a/README.md b/README.md index c985f5b..0645324 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ This component provides only reusable features that can be used across various g * Java OpenJDK 17 * Kotlin 1.9 -* Quarkus 3.4 +* Quarkus 3.5 * Cloud Spanner ### Running the application in dev mode @@ -30,7 +30,7 @@ You can run your application in dev mode that enables live coding. ```shell make init_spanner_emulator -./gradlew quarkusDev +make run_application ``` > **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. @@ -40,7 +40,7 @@ make init_spanner_emulator The application can be packaged. ```shell -./gradlew build +make build ``` It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. @@ -52,27 +52,26 @@ The application is now runnable using `java -jar build/quarkus-app/quarkus-run.j You can create a native executable. ```shell -./gradlew build -Dquarkus.package.type=native +make build_native ``` -Or, if you don't have GraalVM installed, you can run the native executable build in a container. +### Check Dependency updates ```shell -./gradlew build -Dquarkus.package.type=native -Dquarkus.native.container-build=true +make check_dependencies +make update_dependencies ``` -### Check Dependency updates - -[Gradle Versions Plugin](https://github.com/ben-manes/gradle-versions-plugin) checks outdated dependencies. +### Run code formatter ```shell -make check_dependencies +make format ``` -### Run code formatter +### Run test and report coverage -This codebase is formatted by [ktlint](https://github.com/pinterest/ktlint). +When you run tests, a coverage report will be generated in [build/reports/jacoco/test/html](./build/reports/jacoco/test/html). ```shell -make format +make test ``` diff --git a/build.gradle.kts b/build.gradle.kts index 571af7b..b2d5f96 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,11 +1,13 @@ plugins { - kotlin("jvm") version "1.9.10" - kotlin("plugin.allopen") version "1.9.10" + kotlin("jvm") version libs.versions.kotlin + kotlin("plugin.allopen") version libs.versions.kotlin - id("io.quarkus") - id("com.github.ben-manes.versions") version "0.47.0" - id("com.diffplug.spotless") version "6.21.0" - id("org.sonarqube") version "4.4.1.3373" + alias(libs.plugins.versions) + alias(libs.plugins.version.catalog.update) + alias(libs.plugins.gradle.git.properties) + alias(libs.plugins.spotless) + alias(libs.plugins.sonarqube) + alias(libs.plugins.quarkus) application java @@ -27,28 +29,19 @@ repositories { gradlePluginPortal() } -group = "net.averak.gsync" -version = "1.0.0-SNAPSHOT" - -val quarkusPlatformGroupId: String by project -val quarkusPlatformArtifactId: String by project -val quarkusPlatformVersion: String by project - dependencies { // Quarkus - implementation(enforcedPlatform("$quarkusPlatformGroupId:$quarkusPlatformArtifactId:$quarkusPlatformVersion")) - implementation("io.quarkus:quarkus-kotlin") - implementation("io.quarkus:quarkus-resteasy-reactive-jackson") - implementation("io.quarkus:quarkus-hibernate-orm") - implementation("io.quarkus:quarkus-arc") - implementation("io.quarkus:quarkus-resteasy-reactive") - implementation("io.quarkus:quarkus-config-yaml") - implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") + implementation(enforcedPlatform(libs.quarkus.bom)) + implementation(libs.quarkus.kotlin) + implementation(libs.quarkus.resteasy.reactive) + implementation(libs.quarkus.resteasy.reactive.jackson) + implementation(libs.quarkus.hibernate.orm) + implementation(libs.quarkus.arc) + implementation(libs.quarkus.config.yaml) + implementation(libs.kotlin.stdlib.jdk8) // GCP - // implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-parent:2.5.0") - // implementation("com.google.cloud:google-cloud-spanner:6.51.0") - implementation("io.quarkiverse.googlecloudservices:quarkus-google-cloud-spanner:2.5.0") { + implementation(libs.quarkus.google.cloud.spanner) { modules { module("com.google.guava:listenablefuture") { replacedBy("com.google.guava:guava", "listenablefuture is part of guava") @@ -56,17 +49,17 @@ dependencies { } } - // utils - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") - implementation("com.google.guava:guava:32.1.3-jre") - - // test - testImplementation("io.quarkus:quarkus-junit5") - testImplementation("io.github.dvgaba:easy-random-core:6.2.0") - testImplementation("org.apache.groovy:groovy") - testImplementation("org.apache.groovy:groovy-sql") - testImplementation("org.spockframework:spock-core:2.4-M1-groovy-4.0") - testImplementation("org.spockframework:spock-junit4:2.4-M1-groovy-4.0") + // Other utils + implementation(libs.guava) + implementation(libs.jackson.module.kotlin) + + // Test Framework & utils + testImplementation(libs.quarkus.junit5) + testImplementation(libs.spock.core) + testImplementation(libs.spock.junit4) + testImplementation(libs.groovy) + testImplementation(libs.groovy.sql) + testImplementation(libs.easy.random) } java { diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 428c0b9..0000000 --- a/gradle.properties +++ /dev/null @@ -1,5 +0,0 @@ -quarkusPluginId=io.quarkus -quarkusPluginVersion=3.4.1 -quarkusPlatformGroupId=io.quarkus.platform -quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.4.1 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..775ce2c --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,33 @@ +[versions] +easy-random = "6.2.1" +groovy = "4.0.7" +kotlin = "1.9.10" +quarkus = "3.5.0" +spock = "2.4-M1-groovy-4.0" + +[libraries] +easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } +groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } +groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } +guava = "com.google.guava:guava:32.1.3-jre" +jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0-rc1" +kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } +quarkus-arc = { module = "io.quarkus:quarkus-arc", version.ref = "quarkus" } +quarkus-bom = { module = "io.quarkus.platform:quarkus-bom", version.ref = "quarkus" } +quarkus-config-yaml = { module = "io.quarkus:quarkus-config-yaml", version.ref = "quarkus" } +quarkus-google-cloud-spanner = "io.quarkiverse.googlecloudservices:quarkus-google-cloud-spanner:2.6.0" +quarkus-hibernate-orm = { module = "io.quarkus:quarkus-hibernate-orm", version.ref = "quarkus" } +quarkus-junit5 = { module = "io.quarkus:quarkus-junit5", version.ref = "quarkus" } +quarkus-kotlin = { module = "io.quarkus:quarkus-kotlin", version.ref = "quarkus" } +quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } +quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } +spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } +spock-junit4 = { module = "org.spockframework:spock-junit4", version.ref = "spock" } + +[plugins] +gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" +quarkus = { id = "io.quarkus", version.ref = "quarkus" } +sonarqube = "org.sonarqube:4.4.1.3373" +spotless = "com.diffplug.spotless:6.22.0" +version-catalog-update = "nl.littlerobots.version-catalog-update:0.8.1" +versions = "com.github.ben-manes.versions:0.49.0" diff --git a/settings.gradle b/settings.gradle index b70661f..029126b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,11 +1 @@ -pluginManagement { - repositories { - mavenCentral() - gradlePluginPortal() - mavenLocal() - } - plugins { - id "${quarkusPluginId}" version "${quarkusPluginVersion}" - } -} -rootProject.name="gsync" +rootProject.name = "gsync" diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt index 8a531cd..33810c0 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt @@ -6,9 +6,8 @@ import org.jboss.resteasy.reactive.RestResponse @Path("/api/health") class HealthCheckController { - @GET - fun health(): RestResponse { + fun health(): RestResponse { return RestResponse.ok() } } diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt index 108f261..0e785e0 100644 --- a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt +++ b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt @@ -7,22 +7,21 @@ import com.fasterxml.jackson.module.kotlin.KotlinFeature import com.fasterxml.jackson.module.kotlin.KotlinModule class JsonUtils { - companion object { - - private val objectMapper = ObjectMapper() - .registerModule( - KotlinModule.Builder() - .withReflectionCacheSize(512) - .configure(KotlinFeature.NullToEmptyCollection, false) - .configure(KotlinFeature.NullToEmptyMap, false) - .configure(KotlinFeature.NullIsSameAsDefault, false) - .configure(KotlinFeature.SingletonSupport, false) - .configure(KotlinFeature.StrictNullChecks, false) - .build(), - ) - .registerModule(JavaTimeModule()) - .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) + private val objectMapper = + ObjectMapper() + .registerModule( + KotlinModule.Builder() + .withReflectionCacheSize(512) + .configure(KotlinFeature.NullToEmptyCollection, false) + .configure(KotlinFeature.NullToEmptyMap, false) + .configure(KotlinFeature.NullIsSameAsDefault, false) + .configure(KotlinFeature.SingletonSupport, false) + .configure(KotlinFeature.StrictNullChecks, false) + .build(), + ) + .registerModule(JavaTimeModule()) + .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) @JvmStatic fun encode(value: Any?): String { @@ -34,7 +33,10 @@ class JsonUtils { } @JvmStatic - fun decode(json: String, clazz: Class): T { + fun decode( + json: String, + clazz: Class, + ): T { return objectMapper.readValue(json, clazz) } } From 3e702a5f5e22a2f6f0a6f72599804c4c3843dce8 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 28 Oct 2023 15:32:07 +0900 Subject: [PATCH 08/26] =?UTF-8?q?refs=20#7=20Spanner=E3=82=A8=E3=83=9F?= =?UTF-8?q?=E3=83=A5=E3=83=AC=E3=83=BC=E3=82=BF=E3=81=AE=E3=82=BB=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=82=A2=E3=83=83=E3=83=97=E3=82=B9=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=97=E3=83=88=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 9 ++++ Makefile | 13 +++++ README.md | 1 + docker/spanner/Dockerfile | 2 +- .../infrastructure/spanner/SpannerClient.kt | 9 ---- .../infrastructure/json/JsonUtils_UT.groovy | 47 +++++++++++++++++++ 6 files changed, 71 insertions(+), 10 deletions(-) delete mode 100644 src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt create mode 100644 src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c022b..12aed29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,7 @@ jobs: distribution: corretto java-version: 17 cache: gradle + - uses: google-github-actions/setup-gcloud@v1 - name: cache sonar packages uses: actions/cache@v3 @@ -31,6 +32,14 @@ jobs: key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar + - name: launch docker + run: | + docker-compose up -d + + - name: start spanner emulator + run: | + make init_spanner_emulator + - name: backend test run: | ./gradlew test jacocoTestReport diff --git a/Makefile b/Makefile index dab2698..4b05d93 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,7 @@ +GCP_PROJECT_ID=gsync +SPANNER_INSTANCE_ID=gsync +SPANNER_DATABASE_ID=gsync + .PHONY: build build: ./gradlew build -x test @@ -6,6 +10,15 @@ build: build_native: ./gradlew build -x test -Dquarkus.package.type=native -Dquarkus.native.container-build=true +.PHONY: init_spanner_emulator +init_spanner_emulator: + gcloud config configurations create emulator || echo "already exists." + gcloud config set auth/disable_credentials true + gcloud config set project $(GCP_PROJECT_ID) + gcloud config set api_endpoint_overrides/spanner http://localhost:9020/ + gcloud spanner instances create $(SPANNER_INSTANCE_ID) --config=emulator-config --description="test instance" --nodes=1 || echo "already exists." + gcloud spanner databases create $(SPANNER_DATABASE_ID) --instance=$(SPANNER_INSTANCE_ID) || echo "already exists." + .PHONY: run_application run_application: ./gradlew quarkusDev diff --git a/README.md b/README.md index 0645324..b13b9f8 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This component provides only reusable features that can be used across various g You can run your application in dev mode that enables live coding. ```shell +docker compose up -d make init_spanner_emulator make run_application ``` diff --git a/docker/spanner/Dockerfile b/docker/spanner/Dockerfile index 64c44f5..60ae24a 100644 --- a/docker/spanner/Dockerfile +++ b/docker/spanner/Dockerfile @@ -1 +1 @@ -FROM gcr.io/cloud-spanner-emulator/emulator \ No newline at end of file +FROM gcr.io/cloud-spanner-emulator/emulator:1.5.11 \ No newline at end of file diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt b/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt deleted file mode 100644 index cf63b5e..0000000 --- a/src/main/kotlin/net/averak/gsync/infrastructure/spanner/SpannerClient.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.averak.gsync.infrastructure.spanner - -import com.google.cloud.spanner.Spanner -import jakarta.inject.Singleton - -@Singleton -class SpannerClient( - private val spanner: Spanner, -) diff --git a/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy b/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy new file mode 100644 index 0000000..373c919 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy @@ -0,0 +1,47 @@ +package net.averak.gsync.infrastructure.json + +import net.averak.gsync.AbstractSpec + +class JsonUtils_UT extends AbstractSpec { + + def "encode: JSON 文字列にエンコードする"() { + when: + final result = JsonUtils.encode(value) + + then: + result == expectedResult + + where: + value || expectedResult + new SampleValue(100, "hello") || "{\"intValue\":100,\"stringValue\":\"hello\"}" + null || "{}" + } + + def "decode: JSON 文字列をデコードする"() { + given: + final json = "{\"intValue\":100,\"stringValue\":\"hello\"}" + + when: + final result = JsonUtils.decode(json, SampleValue.class) + + then: + result.intValue == 100 + result.stringValue == "hello" + } + + static class SampleValue { + + public Integer intValue + + public String stringValue + + SampleValue() {} + + SampleValue(Integer intValue, String stringValue) { + this.intValue = intValue + this.stringValue = stringValue + } + + } + +} From 35d0b7519cc68182b9ffd69664756800f8b19499 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 28 Oct 2023 16:20:03 +0900 Subject: [PATCH 09/26] =?UTF-8?q?refs=20#14=20=E6=A7=8B=E9=80=A0=E5=8C=96?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=92=E5=87=BA=E5=8A=9B=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E3=81=97=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + src/main/resources/application.yaml | 20 +++++++++++++++----- src/test/resources/application-test.yaml | 0 4 files changed, 17 insertions(+), 5 deletions(-) delete mode 100644 src/test/resources/application-test.yaml diff --git a/build.gradle.kts b/build.gradle.kts index b2d5f96..aacbc94 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.quarkus.hibernate.orm) implementation(libs.quarkus.arc) implementation(libs.quarkus.config.yaml) + implementation(libs.quarkus.logging.json) implementation(libs.kotlin.stdlib.jdk8) // GCP diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 775ce2c..1ee5709 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ quarkus-google-cloud-spanner = "io.quarkiverse.googlecloudservices:quarkus-googl quarkus-hibernate-orm = { module = "io.quarkus:quarkus-hibernate-orm", version.ref = "quarkus" } quarkus-junit5 = { module = "io.quarkus:quarkus-junit5", version.ref = "quarkus" } quarkus-kotlin = { module = "io.quarkus:quarkus-kotlin", version.ref = "quarkus" } +quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref = "quarkus" } quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ff7acd7..9a9ace7 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -2,11 +2,9 @@ quarkus: application: name: "gsync" version: "1.0.0-SNAPSHOT" - # datasource: - # db-kind: spanner - # jdbc: - # url: jdbc:cloudspanner:/projects/${quarkus.google.cloud.project-id}/instances/${quarkus.google.cloud.instance-id}/databases/${quarkus.google.cloud.database-id} - # driver: com.google.cloud.spanner.jdbc.JdbcDriver + log: + console: + json: true google: cloud: project-id: ${GOOGLE_CLOUD_PROJECT_ID:gsync} @@ -14,3 +12,15 @@ quarkus: database-id: ${GOOGLE_CLOUD_SPANNER_DATABASE_NAME:gsync} spanner: emulator-host: http://localhost:9020 + +"%dev": + quarkus: + log: + console: + json: false + +"%test": + quarkus: + log: + console: + json: false diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml deleted file mode 100644 index e69de29..0000000 From 7f864eb8df7511f9fe431593f55f1c73fc3ffc9b Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sun, 29 Oct 2023 23:28:45 +0900 Subject: [PATCH 10/26] =?UTF-8?q?refs=20#17=20=E4=BE=8B=E5=A4=96=E3=83=8F?= =?UTF-8?q?=E3=83=B3=E3=83=89=E3=83=A9=E3=82=92=E5=AE=9A=E7=BE=A9=E3=81=97?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/handler/exception/ErrorResponse.kt | 10 ++++++++++ .../handler/exception/NotFoundExceptionMapper.kt | 15 +++++++++++++++ .../net/averak/gsync/core/exception/ErrorCode.kt | 11 +++++++++++ 3 files changed, 36 insertions(+) create mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt create mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt create mode 100644 src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt new file mode 100644 index 0000000..9ab0595 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt @@ -0,0 +1,10 @@ +package net.averak.gsync.adapter.handler.exception + +import net.averak.gsync.core.exception.ErrorCode + +data class ErrorResponse( + val code: String, + val description: String, +) { + constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.description) +} diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt new file mode 100644 index 0000000..50f6265 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt @@ -0,0 +1,15 @@ +package net.averak.gsync.adapter.handler.exception + +import jakarta.ws.rs.NotFoundException +import jakarta.ws.rs.core.Response +import jakarta.ws.rs.ext.ExceptionMapper +import jakarta.ws.rs.ext.Provider +import net.averak.gsync.core.exception.ErrorCode +import org.apache.http.HttpStatus + +@Provider +class NotFoundExceptionMapper : ExceptionMapper { + override fun toResponse(exception: NotFoundException?): Response { + return Response.status(HttpStatus.SC_NOT_FOUND).entity(ErrorResponse(ErrorCode.NOT_FOUND_API)).build() + } +} diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt new file mode 100644 index 0000000..c79b077 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -0,0 +1,11 @@ +package net.averak.gsync.core.exception + +enum class ErrorCode(val description: String) { + // 400 Bad Request + VALIDATION_ERROR("Request validation exception was thrown."), + INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), + ID_FORMAT_IS_INVALID("ID format is invalid."), + + // 404 Not Found + NOT_FOUND_API("API not found."), +} From 6a9ef7610a1929314835c33ab7a8c00a483754ab Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sun, 29 Oct 2023 23:38:06 +0900 Subject: [PATCH 11/26] =?UTF-8?q?refs=20#17=20=E3=83=93=E3=83=AB=E3=83=89?= =?UTF-8?q?=E3=82=B9=E3=82=AF=E3=83=AA=E3=83=97=E3=83=88=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 12aed29..ec89193 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,4 +79,4 @@ jobs: - name: backend build run: | - ./gradlew build -x test \ No newline at end of file + ./gradlew quarkusBuild \ No newline at end of file From dd878205602cda1899edf990760283a45f5757aa Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sun, 29 Oct 2023 23:00:23 +0900 Subject: [PATCH 12/26] =?UTF-8?q?WIP:=20refs=20#16=20testkit=E3=82=B5?= =?UTF-8?q?=E3=83=96=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=82=92?= =?UTF-8?q?=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- build.gradle.kts | 30 ++- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 1 + .../handler/exception/ErrorResponse.kt | 4 +- .../exception/NotFoundExceptionMapper.kt | 8 +- .../averak/gsync/core/exception/ErrorCode.kt | 2 +- .../gsync/core/exception/GsyncException.kt | 5 + .../net/averak/gsync/domain/model/Echo.kt | 16 ++ .../gsync/domain/primitive/common/ID.kt | 17 ++ .../net/averak/gsync/usecase/EchoUsecase.kt | 11 ++ .../net/averak/gsync/AbstractSpec.groovy | 23 +++ .../controller/AbstractController_IT.groovy | 2 +- .../domain/primitive/common/ID_UT.groovy | 39 ++++ .../primitive/common/IDRandomizer.groovy | 16 ++ .../gsync/usecase/AbstractUsecase_UT.groovy | 6 + .../gsync/usecase/EchoUsecase_UT.groovy | 21 ++ testkit/build.gradle.kts | 24 +++ .../kotlin/net/averak/gsync/testkit/Faker.kt | 185 ++++++++++++++++++ .../net/averak/gsync/testkit/Fixture.kt | 59 ++++++ .../net/averak/gsync/testkit/IRandomizer.kt | 14 ++ 22 files changed, 463 insertions(+), 28 deletions(-) create mode 100644 src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt create mode 100644 src/main/kotlin/net/averak/gsync/domain/model/Echo.kt create mode 100644 src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt create mode 100644 src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt create mode 100644 src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy create mode 100644 src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy create mode 100644 src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy create mode 100644 src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy create mode 100644 testkit/build.gradle.kts create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt diff --git a/Makefile b/Makefile index 4b05d93..c680dd6 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SPANNER_DATABASE_ID=gsync .PHONY: build build: - ./gradlew build -x test + ./gradlew quarkusBuild .PHONY: build_native build_native: diff --git a/build.gradle.kts b/build.gradle.kts index aacbc94..d14bb8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin - kotlin("plugin.allopen") version libs.versions.kotlin alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) @@ -17,15 +16,8 @@ plugins { idea } -buildscript { - dependencies { - classpath("com.github.ben-manes:gradle-versions-plugin:0.47.0") - } -} - repositories { mavenCentral() - mavenLocal() gradlePluginPortal() } @@ -55,27 +47,28 @@ dependencies { implementation(libs.jackson.module.kotlin) // Test Framework & utils + testImplementation(project(":testkit")) testImplementation(libs.quarkus.junit5) testImplementation(libs.spock.core) - testImplementation(libs.spock.junit4) testImplementation(libs.groovy) testImplementation(libs.groovy.sql) testImplementation(libs.easy.random) } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } kotlin { - jvmToolchain(17) -} - -allOpen { - annotation("jakarta.ws.rs.Path") - annotation("jakarta.enterprise.context.ApplicationScoped") - annotation("io.quarkus.test.junit.QuarkusTest") + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } } spotless { @@ -93,6 +86,7 @@ sonar { property("sonar.projectKey", "averak_gsync") property("sonar.organization", "averak") property("sonar.host.url", "https://sonarcloud.io") + property("sonar.exclusions", "testkit/**") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ee5709..5f4a395 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,12 @@ [versions] easy-random = "6.2.1" groovy = "4.0.7" -kotlin = "1.9.10" +kotlin = "1.9.20" quarkus = "3.5.0" spock = "2.4-M1-groovy-4.0" [libraries] +commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } @@ -23,7 +24,6 @@ quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } -spock-junit4 = { module = "org.spockframework:spock-junit4", version.ref = "spock" } [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index db9a6b8..e411586 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 029126b..33b12cc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ rootProject.name = "gsync" +include(":testkit") diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt index 9ab0595..b92c98b 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt @@ -4,7 +4,7 @@ import net.averak.gsync.core.exception.ErrorCode data class ErrorResponse( val code: String, - val description: String, + val summary: String, ) { - constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.description) + constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.summary) } diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt index 50f6265..6576c32 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt @@ -1,15 +1,19 @@ package net.averak.gsync.adapter.handler.exception import jakarta.ws.rs.NotFoundException +import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response import jakarta.ws.rs.ext.ExceptionMapper import jakarta.ws.rs.ext.Provider import net.averak.gsync.core.exception.ErrorCode -import org.apache.http.HttpStatus @Provider class NotFoundExceptionMapper : ExceptionMapper { override fun toResponse(exception: NotFoundException?): Response { - return Response.status(HttpStatus.SC_NOT_FOUND).entity(ErrorResponse(ErrorCode.NOT_FOUND_API)).build() + return Response + .status(Response.Status.NOT_FOUND) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(ErrorResponse(ErrorCode.NOT_FOUND_API)) + .build() } } diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt index c79b077..6501591 100644 --- a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt +++ b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -1,6 +1,6 @@ package net.averak.gsync.core.exception -enum class ErrorCode(val description: String) { +enum class ErrorCode(val summary: String) { // 400 Bad Request VALIDATION_ERROR("Request validation exception was thrown."), INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), diff --git a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt new file mode 100644 index 0000000..a269e3d --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt @@ -0,0 +1,5 @@ +package net.averak.gsync.core.exception + +class GsyncException(val errorCode: ErrorCode, val causedBy: Throwable?) : RuntimeException(errorCode.summary, causedBy) { + constructor(errorCode: ErrorCode) : this(errorCode, null) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt new file mode 100644 index 0000000..2740b67 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt @@ -0,0 +1,16 @@ +package net.averak.gsync.domain.model + +import net.averak.gsync.domain.primitive.common.ID +import java.time.LocalDateTime + +data class Echo( + val id: ID, + val message: String, + val timestamp: LocalDateTime, +) { + constructor(message: String) : this( + ID(), + message, + LocalDateTime.now(), + ) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt new file mode 100644 index 0000000..ef22892 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt @@ -0,0 +1,17 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import java.util.UUID + +data class ID(val value: String) { + init { + try { + UUID.fromString(value) + } catch (_: IllegalArgumentException) { + throw GsyncException(ErrorCode.ID_FORMAT_IS_INVALID) + } + } + + constructor() : this(UUID.randomUUID().toString()) +} diff --git a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt new file mode 100644 index 0000000..390de61 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -0,0 +1,11 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Singleton +import net.averak.gsync.domain.model.Echo + +@Singleton +class EchoUsecase { + fun echo(message: String): Echo { + return Echo(message) + } +} diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy index 93fe2ae..fbc18b6 100644 --- a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy +++ b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy @@ -1,8 +1,31 @@ package net.averak.gsync +import io.quarkus.arc.All import io.quarkus.test.junit.QuarkusTest +import jakarta.annotation.PostConstruct +import jakarta.inject.Inject +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker +import net.averak.gsync.testkit.IRandomizer import spock.lang.Specification @QuarkusTest abstract class AbstractSpec extends Specification { + + @Inject + @All + List randomizers + + @PostConstruct + void init() { + Faker.init(randomizers) + } + + /** + * 例外を検証 + */ + static void verify(final GsyncException actual, final GsyncException expected) { + assert actual.errorCode == expected.errorCode + } + } diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy index e97bce7..ca0759d 100644 --- a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy +++ b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy @@ -2,5 +2,5 @@ package net.averak.gsync.adapter.handler.controller import net.averak.gsync.AbstractSpec -class AbstractController_IT extends AbstractSpec { +abstract class AbstractController_IT extends AbstractSpec { } diff --git a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy new file mode 100644 index 0000000..04e1b45 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy @@ -0,0 +1,39 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.AbstractSpec +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker + +class ID_UT extends AbstractSpec { + + def "constructor: 正常に作成できる"() { + when: + new ID(value) + + then: + noExceptionThrown() + + where: + value << [ + Faker.uuidv4(), + Faker.uuidv5("1"), + ] + } + + def "ID: 制約違反の場合は例外を返す"() { + when: + new ID(value) + + then: + final exception = thrown(GsyncException) + verify(exception, new GsyncException(ErrorCode.ID_FORMAT_IS_INVALID)) + + where: + value << [ + Faker.alphanumeric(36), + Faker.uuidv4() + "A", + ] + } + +} \ No newline at end of file diff --git a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy new file mode 100644 index 0000000..3bf8e90 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy @@ -0,0 +1,16 @@ +package net.averak.gsync.randomizer.domain.primitive.common + +import net.averak.gsync.domain.primitive.common.ID +import net.averak.gsync.testkit.IRandomizer + +@Singleton +class IDRandomizer implements IRandomizer { + + final Class typeToGenerate = ID.class + + @Override + Object getRandomValue() { + return new ID() + } + +} diff --git a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy new file mode 100644 index 0000000..fa65aa0 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -0,0 +1,6 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.AbstractSpec + +abstract class AbstractUsecase_UT extends AbstractSpec { +} diff --git a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy new file mode 100644 index 0000000..a974b1d --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -0,0 +1,21 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Inject +import net.averak.gsync.testkit.Faker + +class EchoUsecase_Echo_UT extends AbstractUsecase_UT { + + @Inject + EchoUsecase sut + + def "echo: 正常系 Echoを作成できる"() { + given: + final message = Faker.alphanumeric() + + when: + final result = sut.echo(message) + + then: + result.message == message + } +} diff --git a/testkit/build.gradle.kts b/testkit/build.gradle.kts new file mode 100644 index 0000000..19bc9e2 --- /dev/null +++ b/testkit/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("jvm") version libs.versions.kotlin +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.groovy.sql) + implementation(libs.easy.random) + implementation(libs.commons.lang3) + implementation(libs.guava) +} + +kotlin { + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt new file mode 100644 index 0000000..3ccafcd --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -0,0 +1,185 @@ +package net.averak.gsync.testkit + +import org.apache.commons.lang3.RandomStringUtils +import org.jeasy.random.EasyRandom +import org.jeasy.random.EasyRandomParameters +import java.time.LocalDate +import java.util.* + +class Faker { + + companion object { + + private lateinit var easyRandom: EasyRandom + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(randomizers: List>) { + val easyRandomParameters = EasyRandomParameters() + randomizers.forEach { + easyRandomParameters.randomize(it.getTypeToGenerate(), it) + } + easyRandom = EasyRandom(easyRandomParameters) + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトを生成する + * + * @param clazz target class + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fake(clazz: Class, fields: Map = mapOf()): T { + val obj = easyRandom.nextObject(clazz) + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + field[obj] = value + field.isAccessible = false + } + return obj + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトリストを生成する + * + * @param clazz target class + * @param size number of generated objects + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fakes(clazz: Class, size: Int = 10, fields: Map = mapOf()): List { + val objs = easyRandom.objects(clazz, size).toList() + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + objs.forEach { field[it] = value } + field.isAccessible = false + } + return objs + } + + /** + * メールアドレスを生成する + */ + @JvmStatic + fun email(): String { + return "${RandomStringUtils.randomAlphanumeric(10)}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) + } + + /** + * パスワードを生成する + */ + @JvmStatic + fun password(): String { + return "b9Fj5QYP" + RandomStringUtils.randomAlphanumeric(8) + } + + /** + * URLを生成する + */ + @JvmStatic + fun url(): String { + return "https://${RandomStringUtils.randomAlphanumeric(5)}.com/${RandomStringUtils.randomAlphanumeric(10)}" + } + + /** + * 数字のみの文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun numeric(length: Int = 31): String { + return RandomStringUtils.randomNumeric(length) + } + + /** + * 英数字の文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun alphanumeric(length: Int = 31): String { + return RandomStringUtils.randomAlphanumeric(length) + } + + /** + * 整数を生成する + */ + @JvmStatic + @JvmOverloads + fun integer(min: Int = 0, max: Int = Int.MAX_VALUE): Int { + val rand = Random() + return min + rand.nextInt(max - min) + } + + /** + * 自然数を生成する + */ + @JvmStatic + @JvmOverloads + fun naturalNumber(max: Int = Int.MAX_VALUE): Int { + return integer(1, max) + } + + /** + * BASE64エンコードされた文字列を生成 + */ + @JvmStatic + fun base64encoded(): String { + val encoder = Base64.getEncoder() + return encoder.encodeToString(RandomStringUtils.randomAlphanumeric(10).toByteArray()) + } + + /** + * リストからランダムに要素を抽出する + */ + @JvmStatic + fun dice(elements: List): T { + return elements[integer(0, elements.size - 1)] + } + + /** + * UUIDv4を生成する + */ + @JvmStatic + fun uuidv4(): String { + return UUID.randomUUID().toString() + } + + /** + * UUIDv5を生成する + */ + @JvmStatic + fun uuidv5(name: String): String { + return UUID.nameUUIDFromBytes(name.toByteArray()).toString() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun today(): LocalDate { + return LocalDate.now() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun tomorrow(): LocalDate { + return LocalDate.now().plusDays(1) + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun yesterday(): LocalDate { + return LocalDate.now().minusDays(1) + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt new file mode 100644 index 0000000..9671164 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt @@ -0,0 +1,59 @@ +package net.averak.gsync.testkit + +import com.google.common.base.CaseFormat +import groovy.sql.Sql + +class Fixture { + + companion object { + + private lateinit var sql: Sql + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(sql: Sql) { + Fixture.sql = sql + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(entity: T): T { + require(entity != null) { + "entity must not be null" + } + + sql.dataSet(extractTableName(entity)).add(extractColumns(entity)) + return entity + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(vararg entities: T): List { + entities.forEach { setup(it) } + return entities.toList() + } + + private fun extractTableName(entity: Any): String { + val tableName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entity.javaClass.simpleName).replace("_entity", "") + return "`$tableName`" + } + + private fun extractColumns(entity: Any): Map { + val result = LinkedHashMap() + entity.javaClass.declaredFields.forEach { + val columnName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.name) + it.isAccessible = true + result["`$columnName`"] = it[entity] + it.isAccessible = false + } + return result + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt new file mode 100644 index 0000000..5b89296 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt @@ -0,0 +1,14 @@ +package net.averak.gsync.testkit + +import org.jeasy.random.api.Randomizer + +/** + * 任意の型の各フィールドにランダム値を格納したインスタンスを生成するための生成ルールセット + * ドメイン制約やDB制約に準拠したオブジェクトを生成したい場合に定義すること + */ +interface IRandomizer : Randomizer { + + override fun getRandomValue(): T + + fun getTypeToGenerate(): Class +} From 6fa1f415971cbf3565015861b8882700ace56615 Mon Sep 17 00:00:00 2001 From: averak Date: Fri, 10 Nov 2023 08:28:48 +0900 Subject: [PATCH 13/26] =?UTF-8?q?Revert=20"=E3=83=86=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=82=A3=E3=83=B3=E3=82=B0=E3=83=A6=E3=83=BC=E3=83=86=E3=82=A3?= =?UTF-8?q?=E3=83=AA=E3=83=86=E3=82=A3=E3=82=92=E4=BD=9C=E6=88=90"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 2 +- build.gradle.kts | 30 +-- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 1 - .../handler/exception/ErrorResponse.kt | 4 +- .../exception/NotFoundExceptionMapper.kt | 8 +- .../averak/gsync/core/exception/ErrorCode.kt | 2 +- .../gsync/core/exception/GsyncException.kt | 5 - .../net/averak/gsync/domain/model/Echo.kt | 16 -- .../gsync/domain/primitive/common/ID.kt | 17 -- .../net/averak/gsync/usecase/EchoUsecase.kt | 11 -- .../net/averak/gsync/AbstractSpec.groovy | 23 --- .../controller/AbstractController_IT.groovy | 2 +- .../domain/primitive/common/ID_UT.groovy | 39 ---- .../primitive/common/IDRandomizer.groovy | 16 -- .../gsync/usecase/AbstractUsecase_UT.groovy | 6 - .../gsync/usecase/EchoUsecase_UT.groovy | 21 -- testkit/build.gradle.kts | 24 --- .../kotlin/net/averak/gsync/testkit/Faker.kt | 185 ------------------ .../net/averak/gsync/testkit/Fixture.kt | 59 ------ .../net/averak/gsync/testkit/IRandomizer.kt | 14 -- 22 files changed, 28 insertions(+), 463 deletions(-) delete mode 100644 src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt delete mode 100644 src/main/kotlin/net/averak/gsync/domain/model/Echo.kt delete mode 100644 src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt delete mode 100644 src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt delete mode 100644 src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy delete mode 100644 src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy delete mode 100644 src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy delete mode 100644 src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy delete mode 100644 testkit/build.gradle.kts delete mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt delete mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt delete mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt diff --git a/Makefile b/Makefile index c680dd6..4b05d93 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SPANNER_DATABASE_ID=gsync .PHONY: build build: - ./gradlew quarkusBuild + ./gradlew build -x test .PHONY: build_native build_native: diff --git a/build.gradle.kts b/build.gradle.kts index d14bb8c..aacbc94 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("jvm") version libs.versions.kotlin + kotlin("plugin.allopen") version libs.versions.kotlin alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) @@ -16,8 +17,15 @@ plugins { idea } +buildscript { + dependencies { + classpath("com.github.ben-manes:gradle-versions-plugin:0.47.0") + } +} + repositories { mavenCentral() + mavenLocal() gradlePluginPortal() } @@ -47,28 +55,27 @@ dependencies { implementation(libs.jackson.module.kotlin) // Test Framework & utils - testImplementation(project(":testkit")) testImplementation(libs.quarkus.junit5) testImplementation(libs.spock.core) + testImplementation(libs.spock.junit4) testImplementation(libs.groovy) testImplementation(libs.groovy.sql) testImplementation(libs.easy.random) } java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlin { - sourceSets { - all { - languageSettings { - languageVersion = "2.0" - } - } - } + jvmToolchain(17) +} + +allOpen { + annotation("jakarta.ws.rs.Path") + annotation("jakarta.enterprise.context.ApplicationScoped") + annotation("io.quarkus.test.junit.QuarkusTest") } spotless { @@ -86,7 +93,6 @@ sonar { property("sonar.projectKey", "averak_gsync") property("sonar.organization", "averak") property("sonar.host.url", "https://sonarcloud.io") - property("sonar.exclusions", "testkit/**") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f4a395..1ee5709 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,11 @@ [versions] easy-random = "6.2.1" groovy = "4.0.7" -kotlin = "1.9.20" +kotlin = "1.9.10" quarkus = "3.5.0" spock = "2.4-M1-groovy-4.0" [libraries] -commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } @@ -24,6 +23,7 @@ quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } +spock-junit4 = { module = "org.spockframework:spock-junit4", version.ref = "spock" } [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e411586..db9a6b8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 33b12cc..029126b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1 @@ rootProject.name = "gsync" -include(":testkit") diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt index b92c98b..9ab0595 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt @@ -4,7 +4,7 @@ import net.averak.gsync.core.exception.ErrorCode data class ErrorResponse( val code: String, - val summary: String, + val description: String, ) { - constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.summary) + constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.description) } diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt index 6576c32..50f6265 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt @@ -1,19 +1,15 @@ package net.averak.gsync.adapter.handler.exception import jakarta.ws.rs.NotFoundException -import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response import jakarta.ws.rs.ext.ExceptionMapper import jakarta.ws.rs.ext.Provider import net.averak.gsync.core.exception.ErrorCode +import org.apache.http.HttpStatus @Provider class NotFoundExceptionMapper : ExceptionMapper { override fun toResponse(exception: NotFoundException?): Response { - return Response - .status(Response.Status.NOT_FOUND) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(ErrorResponse(ErrorCode.NOT_FOUND_API)) - .build() + return Response.status(HttpStatus.SC_NOT_FOUND).entity(ErrorResponse(ErrorCode.NOT_FOUND_API)).build() } } diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt index 6501591..c79b077 100644 --- a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt +++ b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -1,6 +1,6 @@ package net.averak.gsync.core.exception -enum class ErrorCode(val summary: String) { +enum class ErrorCode(val description: String) { // 400 Bad Request VALIDATION_ERROR("Request validation exception was thrown."), INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), diff --git a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt deleted file mode 100644 index a269e3d..0000000 --- a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.averak.gsync.core.exception - -class GsyncException(val errorCode: ErrorCode, val causedBy: Throwable?) : RuntimeException(errorCode.summary, causedBy) { - constructor(errorCode: ErrorCode) : this(errorCode, null) -} diff --git a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt deleted file mode 100644 index 2740b67..0000000 --- a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt +++ /dev/null @@ -1,16 +0,0 @@ -package net.averak.gsync.domain.model - -import net.averak.gsync.domain.primitive.common.ID -import java.time.LocalDateTime - -data class Echo( - val id: ID, - val message: String, - val timestamp: LocalDateTime, -) { - constructor(message: String) : this( - ID(), - message, - LocalDateTime.now(), - ) -} diff --git a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt deleted file mode 100644 index ef22892..0000000 --- a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt +++ /dev/null @@ -1,17 +0,0 @@ -package net.averak.gsync.domain.primitive.common - -import net.averak.gsync.core.exception.ErrorCode -import net.averak.gsync.core.exception.GsyncException -import java.util.UUID - -data class ID(val value: String) { - init { - try { - UUID.fromString(value) - } catch (_: IllegalArgumentException) { - throw GsyncException(ErrorCode.ID_FORMAT_IS_INVALID) - } - } - - constructor() : this(UUID.randomUUID().toString()) -} diff --git a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt deleted file mode 100644 index 390de61..0000000 --- a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.averak.gsync.usecase - -import jakarta.inject.Singleton -import net.averak.gsync.domain.model.Echo - -@Singleton -class EchoUsecase { - fun echo(message: String): Echo { - return Echo(message) - } -} diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy index fbc18b6..93fe2ae 100644 --- a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy +++ b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy @@ -1,31 +1,8 @@ package net.averak.gsync -import io.quarkus.arc.All import io.quarkus.test.junit.QuarkusTest -import jakarta.annotation.PostConstruct -import jakarta.inject.Inject -import net.averak.gsync.core.exception.GsyncException -import net.averak.gsync.testkit.Faker -import net.averak.gsync.testkit.IRandomizer import spock.lang.Specification @QuarkusTest abstract class AbstractSpec extends Specification { - - @Inject - @All - List randomizers - - @PostConstruct - void init() { - Faker.init(randomizers) - } - - /** - * 例外を検証 - */ - static void verify(final GsyncException actual, final GsyncException expected) { - assert actual.errorCode == expected.errorCode - } - } diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy index ca0759d..e97bce7 100644 --- a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy +++ b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy @@ -2,5 +2,5 @@ package net.averak.gsync.adapter.handler.controller import net.averak.gsync.AbstractSpec -abstract class AbstractController_IT extends AbstractSpec { +class AbstractController_IT extends AbstractSpec { } diff --git a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy deleted file mode 100644 index 04e1b45..0000000 --- a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy +++ /dev/null @@ -1,39 +0,0 @@ -package net.averak.gsync.domain.primitive.common - -import net.averak.gsync.AbstractSpec -import net.averak.gsync.core.exception.ErrorCode -import net.averak.gsync.core.exception.GsyncException -import net.averak.gsync.testkit.Faker - -class ID_UT extends AbstractSpec { - - def "constructor: 正常に作成できる"() { - when: - new ID(value) - - then: - noExceptionThrown() - - where: - value << [ - Faker.uuidv4(), - Faker.uuidv5("1"), - ] - } - - def "ID: 制約違反の場合は例外を返す"() { - when: - new ID(value) - - then: - final exception = thrown(GsyncException) - verify(exception, new GsyncException(ErrorCode.ID_FORMAT_IS_INVALID)) - - where: - value << [ - Faker.alphanumeric(36), - Faker.uuidv4() + "A", - ] - } - -} \ No newline at end of file diff --git a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy deleted file mode 100644 index 3bf8e90..0000000 --- a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy +++ /dev/null @@ -1,16 +0,0 @@ -package net.averak.gsync.randomizer.domain.primitive.common - -import net.averak.gsync.domain.primitive.common.ID -import net.averak.gsync.testkit.IRandomizer - -@Singleton -class IDRandomizer implements IRandomizer { - - final Class typeToGenerate = ID.class - - @Override - Object getRandomValue() { - return new ID() - } - -} diff --git a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy deleted file mode 100644 index fa65aa0..0000000 --- a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy +++ /dev/null @@ -1,6 +0,0 @@ -package net.averak.gsync.usecase - -import net.averak.gsync.AbstractSpec - -abstract class AbstractUsecase_UT extends AbstractSpec { -} diff --git a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy deleted file mode 100644 index a974b1d..0000000 --- a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy +++ /dev/null @@ -1,21 +0,0 @@ -package net.averak.gsync.usecase - -import jakarta.inject.Inject -import net.averak.gsync.testkit.Faker - -class EchoUsecase_Echo_UT extends AbstractUsecase_UT { - - @Inject - EchoUsecase sut - - def "echo: 正常系 Echoを作成できる"() { - given: - final message = Faker.alphanumeric() - - when: - final result = sut.echo(message) - - then: - result.message == message - } -} diff --git a/testkit/build.gradle.kts b/testkit/build.gradle.kts deleted file mode 100644 index 19bc9e2..0000000 --- a/testkit/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - kotlin("jvm") version libs.versions.kotlin -} - -repositories { - mavenCentral() -} - -dependencies { - implementation(libs.groovy.sql) - implementation(libs.easy.random) - implementation(libs.commons.lang3) - implementation(libs.guava) -} - -kotlin { - sourceSets { - all { - languageSettings { - languageVersion = "2.0" - } - } - } -} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt deleted file mode 100644 index 3ccafcd..0000000 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt +++ /dev/null @@ -1,185 +0,0 @@ -package net.averak.gsync.testkit - -import org.apache.commons.lang3.RandomStringUtils -import org.jeasy.random.EasyRandom -import org.jeasy.random.EasyRandomParameters -import java.time.LocalDate -import java.util.* - -class Faker { - - companion object { - - private lateinit var easyRandom: EasyRandom - - /** - * 初期化する - * テストのエントリーポイントから必ず呼び出すこと - */ - @JvmStatic - fun init(randomizers: List>) { - val easyRandomParameters = EasyRandomParameters() - randomizers.forEach { - easyRandomParameters.randomize(it.getTypeToGenerate(), it) - } - easyRandom = EasyRandom(easyRandomParameters) - } - - /** - * 各フィールドにランダム値を格納したフェイクオブジェクトを生成する - * - * @param clazz target class - * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ - */ - @JvmStatic - @JvmOverloads - fun fake(clazz: Class, fields: Map = mapOf()): T { - val obj = easyRandom.nextObject(clazz) - fields.forEach { (key, value) -> - val field = clazz.getDeclaredField(key) - field.isAccessible = true - field[obj] = value - field.isAccessible = false - } - return obj - } - - /** - * 各フィールドにランダム値を格納したフェイクオブジェクトリストを生成する - * - * @param clazz target class - * @param size number of generated objects - * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ - */ - @JvmStatic - @JvmOverloads - fun fakes(clazz: Class, size: Int = 10, fields: Map = mapOf()): List { - val objs = easyRandom.objects(clazz, size).toList() - fields.forEach { (key, value) -> - val field = clazz.getDeclaredField(key) - field.isAccessible = true - objs.forEach { field[it] = value } - field.isAccessible = false - } - return objs - } - - /** - * メールアドレスを生成する - */ - @JvmStatic - fun email(): String { - return "${RandomStringUtils.randomAlphanumeric(10)}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) - } - - /** - * パスワードを生成する - */ - @JvmStatic - fun password(): String { - return "b9Fj5QYP" + RandomStringUtils.randomAlphanumeric(8) - } - - /** - * URLを生成する - */ - @JvmStatic - fun url(): String { - return "https://${RandomStringUtils.randomAlphanumeric(5)}.com/${RandomStringUtils.randomAlphanumeric(10)}" - } - - /** - * 数字のみの文字列を生成する - */ - @JvmStatic - @JvmOverloads - fun numeric(length: Int = 31): String { - return RandomStringUtils.randomNumeric(length) - } - - /** - * 英数字の文字列を生成する - */ - @JvmStatic - @JvmOverloads - fun alphanumeric(length: Int = 31): String { - return RandomStringUtils.randomAlphanumeric(length) - } - - /** - * 整数を生成する - */ - @JvmStatic - @JvmOverloads - fun integer(min: Int = 0, max: Int = Int.MAX_VALUE): Int { - val rand = Random() - return min + rand.nextInt(max - min) - } - - /** - * 自然数を生成する - */ - @JvmStatic - @JvmOverloads - fun naturalNumber(max: Int = Int.MAX_VALUE): Int { - return integer(1, max) - } - - /** - * BASE64エンコードされた文字列を生成 - */ - @JvmStatic - fun base64encoded(): String { - val encoder = Base64.getEncoder() - return encoder.encodeToString(RandomStringUtils.randomAlphanumeric(10).toByteArray()) - } - - /** - * リストからランダムに要素を抽出する - */ - @JvmStatic - fun dice(elements: List): T { - return elements[integer(0, elements.size - 1)] - } - - /** - * UUIDv4を生成する - */ - @JvmStatic - fun uuidv4(): String { - return UUID.randomUUID().toString() - } - - /** - * UUIDv5を生成する - */ - @JvmStatic - fun uuidv5(name: String): String { - return UUID.nameUUIDFromBytes(name.toByteArray()).toString() - } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun today(): LocalDate { - return LocalDate.now() - } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun tomorrow(): LocalDate { - return LocalDate.now().plusDays(1) - } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun yesterday(): LocalDate { - return LocalDate.now().minusDays(1) - } - } -} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt deleted file mode 100644 index 9671164..0000000 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt +++ /dev/null @@ -1,59 +0,0 @@ -package net.averak.gsync.testkit - -import com.google.common.base.CaseFormat -import groovy.sql.Sql - -class Fixture { - - companion object { - - private lateinit var sql: Sql - - /** - * 初期化する - * テストのエントリーポイントから必ず呼び出すこと - */ - @JvmStatic - fun init(sql: Sql) { - Fixture.sql = sql - } - - /** - * テストフィクスチャをセットアップする - */ - @JvmStatic - fun setup(entity: T): T { - require(entity != null) { - "entity must not be null" - } - - sql.dataSet(extractTableName(entity)).add(extractColumns(entity)) - return entity - } - - /** - * テストフィクスチャをセットアップする - */ - @JvmStatic - fun setup(vararg entities: T): List { - entities.forEach { setup(it) } - return entities.toList() - } - - private fun extractTableName(entity: Any): String { - val tableName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entity.javaClass.simpleName).replace("_entity", "") - return "`$tableName`" - } - - private fun extractColumns(entity: Any): Map { - val result = LinkedHashMap() - entity.javaClass.declaredFields.forEach { - val columnName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.name) - it.isAccessible = true - result["`$columnName`"] = it[entity] - it.isAccessible = false - } - return result - } - } -} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt deleted file mode 100644 index 5b89296..0000000 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt +++ /dev/null @@ -1,14 +0,0 @@ -package net.averak.gsync.testkit - -import org.jeasy.random.api.Randomizer - -/** - * 任意の型の各フィールドにランダム値を格納したインスタンスを生成するための生成ルールセット - * ドメイン制約やDB制約に準拠したオブジェクトを生成したい場合に定義すること - */ -interface IRandomizer : Randomizer { - - override fun getRandomValue(): T - - fun getTypeToGenerate(): Class -} From c7a4a0c7f607964d2652f402bd87e48f7bc994e6 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Fri, 10 Nov 2023 08:30:50 +0900 Subject: [PATCH 14/26] =?UTF-8?q?refs=20#16=20testkit=E3=82=B5=E3=83=96?= =?UTF-8?q?=E3=83=A2=E3=82=B8=E3=83=A5=E3=83=BC=E3=83=AB=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6fa1f415971cbf3565015861b8882700ace56615. --- Makefile | 2 +- build.gradle.kts | 30 ++- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle | 1 + .../handler/exception/ErrorResponse.kt | 4 +- .../exception/NotFoundExceptionMapper.kt | 8 +- .../averak/gsync/core/exception/ErrorCode.kt | 2 +- .../gsync/core/exception/GsyncException.kt | 5 + .../net/averak/gsync/domain/model/Echo.kt | 16 ++ .../gsync/domain/primitive/common/ID.kt | 17 ++ .../net/averak/gsync/usecase/EchoUsecase.kt | 11 ++ .../net/averak/gsync/AbstractSpec.groovy | 23 +++ .../controller/AbstractController_IT.groovy | 2 +- .../domain/primitive/common/ID_UT.groovy | 39 ++++ .../primitive/common/IDRandomizer.groovy | 16 ++ .../gsync/usecase/AbstractUsecase_UT.groovy | 6 + .../gsync/usecase/EchoUsecase_UT.groovy | 21 ++ testkit/build.gradle.kts | 24 +++ .../kotlin/net/averak/gsync/testkit/Faker.kt | 185 ++++++++++++++++++ .../net/averak/gsync/testkit/Fixture.kt | 59 ++++++ .../net/averak/gsync/testkit/IRandomizer.kt | 14 ++ 22 files changed, 463 insertions(+), 28 deletions(-) create mode 100644 src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt create mode 100644 src/main/kotlin/net/averak/gsync/domain/model/Echo.kt create mode 100644 src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt create mode 100644 src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt create mode 100644 src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy create mode 100644 src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy create mode 100644 src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy create mode 100644 src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy create mode 100644 testkit/build.gradle.kts create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt diff --git a/Makefile b/Makefile index 4b05d93..c680dd6 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ SPANNER_DATABASE_ID=gsync .PHONY: build build: - ./gradlew build -x test + ./gradlew quarkusBuild .PHONY: build_native build_native: diff --git a/build.gradle.kts b/build.gradle.kts index aacbc94..d14bb8c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,5 @@ plugins { kotlin("jvm") version libs.versions.kotlin - kotlin("plugin.allopen") version libs.versions.kotlin alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) @@ -17,15 +16,8 @@ plugins { idea } -buildscript { - dependencies { - classpath("com.github.ben-manes:gradle-versions-plugin:0.47.0") - } -} - repositories { mavenCentral() - mavenLocal() gradlePluginPortal() } @@ -55,27 +47,28 @@ dependencies { implementation(libs.jackson.module.kotlin) // Test Framework & utils + testImplementation(project(":testkit")) testImplementation(libs.quarkus.junit5) testImplementation(libs.spock.core) - testImplementation(libs.spock.junit4) testImplementation(libs.groovy) testImplementation(libs.groovy.sql) testImplementation(libs.easy.random) } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } kotlin { - jvmToolchain(17) -} - -allOpen { - annotation("jakarta.ws.rs.Path") - annotation("jakarta.enterprise.context.ApplicationScoped") - annotation("io.quarkus.test.junit.QuarkusTest") + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } } spotless { @@ -93,6 +86,7 @@ sonar { property("sonar.projectKey", "averak_gsync") property("sonar.organization", "averak") property("sonar.host.url", "https://sonarcloud.io") + property("sonar.exclusions", "testkit/**") } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1ee5709..5f4a395 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,11 +1,12 @@ [versions] easy-random = "6.2.1" groovy = "4.0.7" -kotlin = "1.9.10" +kotlin = "1.9.20" quarkus = "3.5.0" spock = "2.4-M1-groovy-4.0" [libraries] +commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } @@ -23,7 +24,6 @@ quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } -spock-junit4 = { module = "org.spockframework:spock-junit4", version.ref = "spock" } [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index db9a6b8..e411586 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/settings.gradle b/settings.gradle index 029126b..33b12cc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ rootProject.name = "gsync" +include(":testkit") diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt index 9ab0595..b92c98b 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt @@ -4,7 +4,7 @@ import net.averak.gsync.core.exception.ErrorCode data class ErrorResponse( val code: String, - val description: String, + val summary: String, ) { - constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.description) + constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.summary) } diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt index 50f6265..6576c32 100644 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt +++ b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt @@ -1,15 +1,19 @@ package net.averak.gsync.adapter.handler.exception import jakarta.ws.rs.NotFoundException +import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.Response import jakarta.ws.rs.ext.ExceptionMapper import jakarta.ws.rs.ext.Provider import net.averak.gsync.core.exception.ErrorCode -import org.apache.http.HttpStatus @Provider class NotFoundExceptionMapper : ExceptionMapper { override fun toResponse(exception: NotFoundException?): Response { - return Response.status(HttpStatus.SC_NOT_FOUND).entity(ErrorResponse(ErrorCode.NOT_FOUND_API)).build() + return Response + .status(Response.Status.NOT_FOUND) + .type(MediaType.APPLICATION_JSON_TYPE) + .entity(ErrorResponse(ErrorCode.NOT_FOUND_API)) + .build() } } diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt index c79b077..6501591 100644 --- a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt +++ b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -1,6 +1,6 @@ package net.averak.gsync.core.exception -enum class ErrorCode(val description: String) { +enum class ErrorCode(val summary: String) { // 400 Bad Request VALIDATION_ERROR("Request validation exception was thrown."), INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), diff --git a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt new file mode 100644 index 0000000..a269e3d --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt @@ -0,0 +1,5 @@ +package net.averak.gsync.core.exception + +class GsyncException(val errorCode: ErrorCode, val causedBy: Throwable?) : RuntimeException(errorCode.summary, causedBy) { + constructor(errorCode: ErrorCode) : this(errorCode, null) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt new file mode 100644 index 0000000..2740b67 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt @@ -0,0 +1,16 @@ +package net.averak.gsync.domain.model + +import net.averak.gsync.domain.primitive.common.ID +import java.time.LocalDateTime + +data class Echo( + val id: ID, + val message: String, + val timestamp: LocalDateTime, +) { + constructor(message: String) : this( + ID(), + message, + LocalDateTime.now(), + ) +} diff --git a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt new file mode 100644 index 0000000..ef22892 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt @@ -0,0 +1,17 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import java.util.UUID + +data class ID(val value: String) { + init { + try { + UUID.fromString(value) + } catch (_: IllegalArgumentException) { + throw GsyncException(ErrorCode.ID_FORMAT_IS_INVALID) + } + } + + constructor() : this(UUID.randomUUID().toString()) +} diff --git a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt new file mode 100644 index 0000000..390de61 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -0,0 +1,11 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Singleton +import net.averak.gsync.domain.model.Echo + +@Singleton +class EchoUsecase { + fun echo(message: String): Echo { + return Echo(message) + } +} diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy index 93fe2ae..fbc18b6 100644 --- a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy +++ b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy @@ -1,8 +1,31 @@ package net.averak.gsync +import io.quarkus.arc.All import io.quarkus.test.junit.QuarkusTest +import jakarta.annotation.PostConstruct +import jakarta.inject.Inject +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker +import net.averak.gsync.testkit.IRandomizer import spock.lang.Specification @QuarkusTest abstract class AbstractSpec extends Specification { + + @Inject + @All + List randomizers + + @PostConstruct + void init() { + Faker.init(randomizers) + } + + /** + * 例外を検証 + */ + static void verify(final GsyncException actual, final GsyncException expected) { + assert actual.errorCode == expected.errorCode + } + } diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy index e97bce7..ca0759d 100644 --- a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy +++ b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy @@ -2,5 +2,5 @@ package net.averak.gsync.adapter.handler.controller import net.averak.gsync.AbstractSpec -class AbstractController_IT extends AbstractSpec { +abstract class AbstractController_IT extends AbstractSpec { } diff --git a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy new file mode 100644 index 0000000..04e1b45 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy @@ -0,0 +1,39 @@ +package net.averak.gsync.domain.primitive.common + +import net.averak.gsync.AbstractSpec +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.testkit.Faker + +class ID_UT extends AbstractSpec { + + def "constructor: 正常に作成できる"() { + when: + new ID(value) + + then: + noExceptionThrown() + + where: + value << [ + Faker.uuidv4(), + Faker.uuidv5("1"), + ] + } + + def "ID: 制約違反の場合は例外を返す"() { + when: + new ID(value) + + then: + final exception = thrown(GsyncException) + verify(exception, new GsyncException(ErrorCode.ID_FORMAT_IS_INVALID)) + + where: + value << [ + Faker.alphanumeric(36), + Faker.uuidv4() + "A", + ] + } + +} \ No newline at end of file diff --git a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy new file mode 100644 index 0000000..3bf8e90 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy @@ -0,0 +1,16 @@ +package net.averak.gsync.randomizer.domain.primitive.common + +import net.averak.gsync.domain.primitive.common.ID +import net.averak.gsync.testkit.IRandomizer + +@Singleton +class IDRandomizer implements IRandomizer { + + final Class typeToGenerate = ID.class + + @Override + Object getRandomValue() { + return new ID() + } + +} diff --git a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy new file mode 100644 index 0000000..fa65aa0 --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -0,0 +1,6 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.AbstractSpec + +abstract class AbstractUsecase_UT extends AbstractSpec { +} diff --git a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy new file mode 100644 index 0000000..a974b1d --- /dev/null +++ b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -0,0 +1,21 @@ +package net.averak.gsync.usecase + +import jakarta.inject.Inject +import net.averak.gsync.testkit.Faker + +class EchoUsecase_Echo_UT extends AbstractUsecase_UT { + + @Inject + EchoUsecase sut + + def "echo: 正常系 Echoを作成できる"() { + given: + final message = Faker.alphanumeric() + + when: + final result = sut.echo(message) + + then: + result.message == message + } +} diff --git a/testkit/build.gradle.kts b/testkit/build.gradle.kts new file mode 100644 index 0000000..19bc9e2 --- /dev/null +++ b/testkit/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + kotlin("jvm") version libs.versions.kotlin +} + +repositories { + mavenCentral() +} + +dependencies { + implementation(libs.groovy.sql) + implementation(libs.easy.random) + implementation(libs.commons.lang3) + implementation(libs.guava) +} + +kotlin { + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt new file mode 100644 index 0000000..3ccafcd --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -0,0 +1,185 @@ +package net.averak.gsync.testkit + +import org.apache.commons.lang3.RandomStringUtils +import org.jeasy.random.EasyRandom +import org.jeasy.random.EasyRandomParameters +import java.time.LocalDate +import java.util.* + +class Faker { + + companion object { + + private lateinit var easyRandom: EasyRandom + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(randomizers: List>) { + val easyRandomParameters = EasyRandomParameters() + randomizers.forEach { + easyRandomParameters.randomize(it.getTypeToGenerate(), it) + } + easyRandom = EasyRandom(easyRandomParameters) + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトを生成する + * + * @param clazz target class + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fake(clazz: Class, fields: Map = mapOf()): T { + val obj = easyRandom.nextObject(clazz) + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + field[obj] = value + field.isAccessible = false + } + return obj + } + + /** + * 各フィールドにランダム値を格納したフェイクオブジェクトリストを生成する + * + * @param clazz target class + * @param size number of generated objects + * @param fields ランダムに生成されたフィールドの値を強制上書きするマップ + */ + @JvmStatic + @JvmOverloads + fun fakes(clazz: Class, size: Int = 10, fields: Map = mapOf()): List { + val objs = easyRandom.objects(clazz, size).toList() + fields.forEach { (key, value) -> + val field = clazz.getDeclaredField(key) + field.isAccessible = true + objs.forEach { field[it] = value } + field.isAccessible = false + } + return objs + } + + /** + * メールアドレスを生成する + */ + @JvmStatic + fun email(): String { + return "${RandomStringUtils.randomAlphanumeric(10)}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) + } + + /** + * パスワードを生成する + */ + @JvmStatic + fun password(): String { + return "b9Fj5QYP" + RandomStringUtils.randomAlphanumeric(8) + } + + /** + * URLを生成する + */ + @JvmStatic + fun url(): String { + return "https://${RandomStringUtils.randomAlphanumeric(5)}.com/${RandomStringUtils.randomAlphanumeric(10)}" + } + + /** + * 数字のみの文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun numeric(length: Int = 31): String { + return RandomStringUtils.randomNumeric(length) + } + + /** + * 英数字の文字列を生成する + */ + @JvmStatic + @JvmOverloads + fun alphanumeric(length: Int = 31): String { + return RandomStringUtils.randomAlphanumeric(length) + } + + /** + * 整数を生成する + */ + @JvmStatic + @JvmOverloads + fun integer(min: Int = 0, max: Int = Int.MAX_VALUE): Int { + val rand = Random() + return min + rand.nextInt(max - min) + } + + /** + * 自然数を生成する + */ + @JvmStatic + @JvmOverloads + fun naturalNumber(max: Int = Int.MAX_VALUE): Int { + return integer(1, max) + } + + /** + * BASE64エンコードされた文字列を生成 + */ + @JvmStatic + fun base64encoded(): String { + val encoder = Base64.getEncoder() + return encoder.encodeToString(RandomStringUtils.randomAlphanumeric(10).toByteArray()) + } + + /** + * リストからランダムに要素を抽出する + */ + @JvmStatic + fun dice(elements: List): T { + return elements[integer(0, elements.size - 1)] + } + + /** + * UUIDv4を生成する + */ + @JvmStatic + fun uuidv4(): String { + return UUID.randomUUID().toString() + } + + /** + * UUIDv5を生成する + */ + @JvmStatic + fun uuidv5(name: String): String { + return UUID.nameUUIDFromBytes(name.toByteArray()).toString() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun today(): LocalDate { + return LocalDate.now() + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun tomorrow(): LocalDate { + return LocalDate.now().plusDays(1) + } + + /** + * 本日の日付を生成する + */ + @JvmStatic + fun yesterday(): LocalDate { + return LocalDate.now().minusDays(1) + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt new file mode 100644 index 0000000..9671164 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt @@ -0,0 +1,59 @@ +package net.averak.gsync.testkit + +import com.google.common.base.CaseFormat +import groovy.sql.Sql + +class Fixture { + + companion object { + + private lateinit var sql: Sql + + /** + * 初期化する + * テストのエントリーポイントから必ず呼び出すこと + */ + @JvmStatic + fun init(sql: Sql) { + Fixture.sql = sql + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(entity: T): T { + require(entity != null) { + "entity must not be null" + } + + sql.dataSet(extractTableName(entity)).add(extractColumns(entity)) + return entity + } + + /** + * テストフィクスチャをセットアップする + */ + @JvmStatic + fun setup(vararg entities: T): List { + entities.forEach { setup(it) } + return entities.toList() + } + + private fun extractTableName(entity: Any): String { + val tableName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entity.javaClass.simpleName).replace("_entity", "") + return "`$tableName`" + } + + private fun extractColumns(entity: Any): Map { + val result = LinkedHashMap() + entity.javaClass.declaredFields.forEach { + val columnName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.name) + it.isAccessible = true + result["`$columnName`"] = it[entity] + it.isAccessible = false + } + return result + } + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt new file mode 100644 index 0000000..5b89296 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/IRandomizer.kt @@ -0,0 +1,14 @@ +package net.averak.gsync.testkit + +import org.jeasy.random.api.Randomizer + +/** + * 任意の型の各フィールドにランダム値を格納したインスタンスを生成するための生成ルールセット + * ドメイン制約やDB制約に準拠したオブジェクトを生成したい場合に定義すること + */ +interface IRandomizer : Randomizer { + + override fun getRandomValue(): T + + fun getTypeToGenerate(): Class +} From 44b27609ac2c3bcc1a7cee721f4420a8faea9ccd Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Sat, 11 Nov 2023 13:23:36 +0900 Subject: [PATCH 15/26] =?UTF-8?q?refs=20#29=20docker-compose=E3=81=A7Spann?= =?UTF-8?q?er=E3=82=A4=E3=83=B3=E3=82=B9=E3=82=BF=E3=83=B3=E3=82=B9?= =?UTF-8?q?=E3=82=92=E4=BD=9C=E6=88=90=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 4 ---- Makefile | 9 --------- README.md | 1 - compose.yaml | 17 ++++++++++++++++- docker/spanner/Dockerfile | 1 - 5 files changed, 16 insertions(+), 16 deletions(-) delete mode 100644 docker/spanner/Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec89193..df4459f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,10 +36,6 @@ jobs: run: | docker-compose up -d - - name: start spanner emulator - run: | - make init_spanner_emulator - - name: backend test run: | ./gradlew test jacocoTestReport diff --git a/Makefile b/Makefile index c680dd6..d4aa8ba 100644 --- a/Makefile +++ b/Makefile @@ -10,15 +10,6 @@ build: build_native: ./gradlew build -x test -Dquarkus.package.type=native -Dquarkus.native.container-build=true -.PHONY: init_spanner_emulator -init_spanner_emulator: - gcloud config configurations create emulator || echo "already exists." - gcloud config set auth/disable_credentials true - gcloud config set project $(GCP_PROJECT_ID) - gcloud config set api_endpoint_overrides/spanner http://localhost:9020/ - gcloud spanner instances create $(SPANNER_INSTANCE_ID) --config=emulator-config --description="test instance" --nodes=1 || echo "already exists." - gcloud spanner databases create $(SPANNER_DATABASE_ID) --instance=$(SPANNER_INSTANCE_ID) || echo "already exists." - .PHONY: run_application run_application: ./gradlew quarkusDev diff --git a/README.md b/README.md index b13b9f8..f28e5e4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,6 @@ You can run your application in dev mode that enables live coding. ```shell docker compose up -d -make init_spanner_emulator make run_application ``` diff --git a/compose.yaml b/compose.yaml index 8c70dde..9b31104 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,6 +1,21 @@ services: spanner: - build: ./docker/spanner + image: gcr.io/cloud-spanner-emulator/emulator ports: - "9010:9010" - "9020:9020" + + spanner-emulator-init: + image: gcr.io/google.com/cloudsdktool/cloud-sdk:slim + command: > + bash -c 'gcloud config configurations create emulator; + gcloud config set auth/disable_credentials true; + gcloud config set project $${PROJECT_ID}; + gcloud config set api_endpoint_overrides/spanner $${SPANNER_EMULATOR_URL}; + gcloud spanner instances create $${INSTANCE_NAME} --config=emulator-config --description=Emulator --nodes=1; + gcloud spanner databases create $${DATABASE_NAME} --instance=$${INSTANCE_NAME}' + environment: + PROJECT_ID: "gsync" + SPANNER_EMULATOR_URL: "http://spanner-emulator:9020/" + INSTANCE_NAME: "sandbox" + DATABASE_NAME: "test" \ No newline at end of file diff --git a/docker/spanner/Dockerfile b/docker/spanner/Dockerfile deleted file mode 100644 index 60ae24a..0000000 --- a/docker/spanner/Dockerfile +++ /dev/null @@ -1 +0,0 @@ -FROM gcr.io/cloud-spanner-emulator/emulator:1.5.11 \ No newline at end of file From a5d9c546bd1a9c706feb7df8edca98f88b4c5554 Mon Sep 17 00:00:00 2001 From: averak Date: Mon, 8 Jan 2024 14:46:09 +0900 Subject: [PATCH 16/26] =?UTF-8?q?refs=20#33=20Redis=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 8 -------- compose.yaml | 10 ++++++++-- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index d4aa8ba..63a2957 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,3 @@ -GCP_PROJECT_ID=gsync -SPANNER_INSTANCE_ID=gsync -SPANNER_DATABASE_ID=gsync - .PHONY: build build: ./gradlew quarkusBuild @@ -10,10 +6,6 @@ build: build_native: ./gradlew build -x test -Dquarkus.package.type=native -Dquarkus.native.container-build=true -.PHONY: run_application -run_application: - ./gradlew quarkusDev - .PHONY: test test: ./gradlew test jacocoTestReport diff --git a/compose.yaml b/compose.yaml index 9b31104..be951a2 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,6 +7,7 @@ services: spanner-emulator-init: image: gcr.io/google.com/cloudsdktool/cloud-sdk:slim + platform: linux/x86_64 command: > bash -c 'gcloud config configurations create emulator; gcloud config set auth/disable_credentials true; @@ -15,7 +16,12 @@ services: gcloud spanner instances create $${INSTANCE_NAME} --config=emulator-config --description=Emulator --nodes=1; gcloud spanner databases create $${DATABASE_NAME} --instance=$${INSTANCE_NAME}' environment: - PROJECT_ID: "gsync" + PROJECT_ID: "gsync-sandbox" SPANNER_EMULATOR_URL: "http://spanner-emulator:9020/" INSTANCE_NAME: "sandbox" - DATABASE_NAME: "test" \ No newline at end of file + DATABASE_NAME: "local" + + redis: + image: redis:7.0 + ports: + - "6379:6379" From cb66288724291d161c6933d0a3b34c2d5f6e3456 Mon Sep 17 00:00:00 2001 From: averak Date: Mon, 8 Jan 2024 14:46:21 +0900 Subject: [PATCH 17/26] =?UTF-8?q?refs=20#33=20spotless=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=AB=E3=82=92=E3=82=AB=E3=82=B9=E3=82=BF=E3=83=9E=E3=82=A4?= =?UTF-8?q?=E3=82=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 17 +++++++++++++++++ build.gradle.kts | 18 ++++++++++++++---- .../gsync/infrastructure/json/JsonUtils.kt | 5 +---- 3 files changed, 32 insertions(+), 8 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..b7e14c1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +max_line_length = 140 + +[*.{kt,kts}] +ktlint_code_style = intellij_idea +ktlint_standard_no-wildcard-imports = disabled +ktlint_standard_package-name = disabled +ktlint_standard_max-line-length = disabled + +[*.md] +max_line_length = off diff --git a/build.gradle.kts b/build.gradle.kts index d14bb8c..1ca1461 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -73,11 +73,21 @@ kotlin { spotless { kotlin { - targetExclude("build/**/*.kt") + targetExclude("build/**") ktlint() - trimTrailingWhitespace() - indentWithSpaces() - endWithNewline() + .setEditorConfigPath("$rootDir/.editorconfig") + .editorConfigOverride( + mapOf( + // .editorconfig のルール無効化設定を読み込んでくれないので、再度設定する必要がある + "ktlint_standard_no-wildcard-imports" to "disabled", + "ktlint_standard_package-name" to "disabled", + "ktlint_standard_max-line-length" to "disabled", + ), + ) + } + + groovy { + targetExclude("build/**") } } diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt index 0e785e0..d67329e 100644 --- a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt +++ b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt @@ -33,10 +33,7 @@ class JsonUtils { } @JvmStatic - fun decode( - json: String, - clazz: Class, - ): T { + fun decode(json: String, clazz: Class): T { return objectMapper.readValue(json, clazz) } } From 5170718b8868df2bb79208cd63cf7ae4cbf06731 Mon Sep 17 00:00:00 2001 From: averak Date: Mon, 8 Jan 2024 15:12:24 +0900 Subject: [PATCH 18/26] =?UTF-8?q?refs=20#35=20Gradle=20multi-project?= =?UTF-8?q?=E6=A7=8B=E6=88=90=E3=81=AB=E7=A7=BB=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 5 - .gitignore | 6 - Makefile | 6 +- README.md | 44 +--- .../rest/GlobalRestControllerAdvice.kt | 71 +++++ .../handler/rest/HealthCheckController.kt | 24 ++ .../adapter/handler/rest/HttpRequestScope.kt | 68 +++++ .../handler/rest/config/WebMvcConfig.kt | 23 ++ .../rest/interceptor/AccessLogInterceptor.kt | 48 ++++ .../interceptor/GameContextInterceptor.kt | 40 +++ .../rest/interceptor/IRequestInterceptor.kt | 17 ++ .../rest/interceptor/InterceptorPriority.kt | 11 + .../handler/rest/AbstractController_IT.groovy | 159 +++++++++++ .../rest/GlobalRestControllerAdvice_IT.groovy | 16 ++ .../rest/HealthCheckController_IT.groovy | 16 ++ .../net/averak/gsync/core/config/Config.kt | 14 + .../averak/gsync/core/daterange/DateRange.kt | 28 ++ .../averak/gsync/core/daterange/Dateline.kt | 77 ++++++ .../averak/gsync/core/exception/ErrorCode.kt | 6 + .../gsync/core/exception/GsyncException.kt | 10 + .../gsync/core/gamecontext/GameContext.kt | 20 ++ .../net/averak/gsync/core/logger/Logger.kt | 67 +++++ .../net/averak/gsync/domain/model/Echo.kt | 11 +- .../gsync/infrastructure/json/JsonConfig.kt | 30 +++ .../gsync/infrastructure/json/JsonUtils.kt | 40 +++ .../infrastructure/json/JsonUtils_UT.groovy | 46 ++++ .../net/averak/gsync/usecase/EchoUsecase.kt | 13 + .../gsync/usecase/AbstractUsecase_UT.groovy | 2 +- .../gsync/usecase/EchoUsecase_UT.groovy | 24 ++ build.gradle.kts | 248 ++++++++++++------ compose.yaml | 2 +- gradle/libs.versions.toml | 29 +- settings.gradle | 12 + src/main/docker/Dockerfile.jvm | 97 ------- src/main/docker/Dockerfile.legacy-jar | 93 ------- src/main/docker/Dockerfile.native | 27 -- src/main/docker/Dockerfile.native-micro | 30 --- .../kotlin/net/averak/gsync/Entrypoint.kt | 25 ++ .../controller/HealthCheckController.kt | 13 - .../handler/exception/ErrorResponse.kt | 10 - .../exception/NotFoundExceptionMapper.kt | 19 -- .../averak/gsync/core/exception/ErrorCode.kt | 11 - .../gsync/core/exception/GsyncException.kt | 5 - .../gsync/domain/primitive/common/ID.kt | 17 -- .../gsync/infrastructure/json/JsonUtils.kt | 40 --- .../net/averak/gsync/usecase/EchoUsecase.kt | 11 - src/main/resources/application.yaml | 62 +++-- src/main/resources/logback.xml | 24 ++ .../net/averak/gsync/AbstractSpec.groovy | 31 --- .../groovy/net/averak/gsync/TestConfig.groovy | 7 - .../controller/AbstractController_IT.groovy | 6 - .../domain/primitive/common/ID_UT.groovy | 39 --- .../infrastructure/json/JsonUtils_UT.groovy | 47 ---- .../primitive/common/IDRandomizer.groovy | 16 -- .../gsync/usecase/EchoUsecase_UT.groovy | 21 -- src/test/resources/application-test.yaml | 7 + src/test/resources/logback-test.xml | 17 ++ testkit/build.gradle.kts | 24 -- .../gsync/testkit/AbstractDatabaseSpec.groovy | 23 ++ .../averak/gsync/testkit/AbstractSpec.groovy | 13 + .../net/averak/gsync/testkit/Assert.groovy | 30 +++ .../kotlin/net/averak/gsync/testkit/Faker.kt | 29 +- .../net/averak/gsync/testkit/TestConfig.kt | 40 +++ 63 files changed, 1308 insertions(+), 759 deletions(-) delete mode 100644 .dockerignore create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HealthCheckController.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/config/WebMvcConfig.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/IRequestInterceptor.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/InterceptorPriority.kt create mode 100644 app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy create mode 100644 app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy create mode 100644 app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/config/Config.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt create mode 100644 app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt rename {src => app/domain/src}/main/kotlin/net/averak/gsync/domain/model/Echo.kt (53%) create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonConfig.kt create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt create mode 100644 app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy create mode 100644 app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt rename {src => app/usecase/src}/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy (67%) create mode 100644 app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy delete mode 100644 src/main/docker/Dockerfile.jvm delete mode 100644 src/main/docker/Dockerfile.legacy-jar delete mode 100644 src/main/docker/Dockerfile.native delete mode 100644 src/main/docker/Dockerfile.native-micro create mode 100644 src/main/kotlin/net/averak/gsync/Entrypoint.kt delete mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt delete mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt delete mode 100644 src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt delete mode 100644 src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt delete mode 100644 src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt delete mode 100644 src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt delete mode 100644 src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt delete mode 100644 src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt create mode 100644 src/main/resources/logback.xml delete mode 100644 src/test/groovy/net/averak/gsync/AbstractSpec.groovy delete mode 100644 src/test/groovy/net/averak/gsync/TestConfig.groovy delete mode 100644 src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy delete mode 100644 src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy delete mode 100644 src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy delete mode 100644 src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy delete mode 100644 src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy create mode 100644 src/test/resources/application-test.yaml create mode 100644 src/test/resources/logback-test.xml delete mode 100644 testkit/build.gradle.kts create mode 100644 testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy create mode 100644 testkit/src/main/groovy/net/averak/gsync/testkit/AbstractSpec.groovy create mode 100644 testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy create mode 100644 testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 4361d2f..0000000 --- a/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -* -!build/*-runner -!build/*-runner.jar -!build/lib/* -!build/quarkus-app/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 216783d..a1eff0e 100644 --- a/.gitignore +++ b/.gitignore @@ -31,9 +31,3 @@ nb-configuration.xml # patch *.orig *.rej - -# Local environment -.env - -# Plugin directory -/.quarkus/cli/plugins/ diff --git a/Makefile b/Makefile index 63a2957..da08a3c 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,6 @@ .PHONY: build build: - ./gradlew quarkusBuild - -.PHONY: build_native -build_native: - ./gradlew build -x test -Dquarkus.package.type=native -Dquarkus.native.container-build=true + ./gradlew bootJar .PHONY: test test: diff --git a/README.md b/README.md index f28e5e4..04c4942 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # gsync ![CI](https://github.com/averak/gsync/workflows/CI/badge.svg) -![version](https://img.shields.io/badge/version-1.0.0--SNAPSHOT-blue.svg) This is a multi-tenancy game server for MO games. @@ -17,24 +16,27 @@ This component provides only reusable features that can be used across various g ## Develop +This document only contains minimal setup instructions to launch the application. + +For more information, see [Makefile](./Makefile). + ### Environments * Java OpenJDK 17 * Kotlin 1.9 -* Quarkus 3.5 +* Spring Boot 3.2 * Cloud Spanner +* Redis ### Running the application in dev mode -You can run your application in dev mode that enables live coding. +You can run your application in dev mode. ```shell docker compose up -d -make run_application +./gradlew bootRun ``` -> **_NOTE:_** Quarkus now ships with a Dev UI, which is available in dev mode only at http://localhost:8080/q/dev/. - ### Packaging and running the application The application can be packaged. @@ -43,35 +45,17 @@ The application can be packaged. make build ``` -It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. - -The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. +It produces the `gsync.jar` file in the `build/libs/` directory. -### Creating a native executable - -You can create a native executable. - -```shell -make build_native -``` +The application is now runnable using `java -jar build/libs/gsync.jar`. ### Check Dependency updates -```shell -make check_dependencies -make update_dependencies -``` - -### Run code formatter +Follow steps [littlerobots/version-catalog-update-plugin](https://github.com/littlerobots/version-catalog-update-plugin?tab=readme-ov-file#interactive-mode) to update outdated dependencies. ```shell -make format -``` +./gradlew versionCatalogUpdate --interactive -### Run test and report coverage - -When you run tests, a coverage report will be generated in [build/reports/jacoco/test/html](./build/reports/jacoco/test/html). - -```shell -make test +# Check the execution plan automatically generated in `gradle/libs.version.updates.toml` and apply if there is no problem. +./gradlew versionCatalogApplyUpdates ``` diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt new file mode 100644 index 0000000..f276df3 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt @@ -0,0 +1,71 @@ +package net.averak.gsync.adapter.handler.rest + +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.core.logger.Logger +import org.apache.catalina.connector.ClientAbortException +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestControllerAdvice +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler + +@Controller +@RestControllerAdvice +class GlobalRestControllerAdvice( + private val customLogger: Logger, + private val requestScope: HttpRequestScope, +) : ResponseEntityExceptionHandler() { + + @RequestMapping("/**") + fun handleApiNotFound(): ResponseEntity { + return makeResponseAfterLogging(GsyncException(ErrorCode.NOT_FOUND_API)) + } + + @ExceptionHandler(Exception::class) + fun handleException(ex: Exception): ResponseEntity { + return makeResponseAfterLogging(ex) + } + + @ExceptionHandler(GsyncException::class) + fun handleException(ex: GsyncException): ResponseEntity { + return makeResponseAfterLogging(ex) + } + + @ExceptionHandler(ClientAbortException::class) + fun handleException(ex: ClientAbortException) { + // クライアントがリクエストを中断した場合は警告ログを残し、処理を中断する + customLogger.warn(requestScope.getGameContext(), ex) + } + + @SuppressWarnings("kotlin:S6510") + private fun makeResponseAfterLogging(ex: Exception): ResponseEntity { + val e = if (ex is GsyncException) { + ex + } else { + GsyncException(ex) + } + + when (e.errorCode) { + ErrorCode.NOT_FOUND_API -> { + this.customLogger.warn(requestScope.getGameContext(), e) + return ResponseEntity(ErrorResponse(e), HttpStatus.NOT_FOUND) + } + + else -> { + this.customLogger.error(requestScope.getGameContext(), e) + return ResponseEntity(ErrorResponse(e), HttpStatus.INTERNAL_SERVER_ERROR) + } + } + } + + data class ErrorResponse( + val code: String, + val message: String, + ) { + + constructor(ex: GsyncException) : this(ex.errorCode.name, ex.errorCode.summary) + } +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HealthCheckController.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HealthCheckController.kt new file mode 100644 index 0000000..ada8299 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HealthCheckController.kt @@ -0,0 +1,24 @@ +package net.averak.gsync.adapter.handler.rest + +import net.averak.gsync.usecase.EchoUsecase +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping(path = ["/api/health"], produces = [MediaType.APPLICATION_JSON_VALUE]) +class HealthCheckController( + private val requestScope: HttpRequestScope, + private val echoUsecase: EchoUsecase, +) { + + @GetMapping + @ResponseStatus(HttpStatus.OK) + fun healthCheck() { + val gctx = requestScope.getGameContext() + echoUsecase.echo(gctx, "Health Check") + } +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt new file mode 100644 index 0000000..930770e --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt @@ -0,0 +1,68 @@ +package net.averak.gsync.adapter.handler.rest + +import jakarta.servlet.http.HttpServletRequest +import net.averak.gsync.core.config.Config +import net.averak.gsync.core.gamecontext.GameContext +import org.springframework.stereotype.Component +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Component +class HttpRequestScope( + private val config: Config, + private val httpServletRequest: HttpServletRequest, +) { + + /** + * インターセプターで書き込まれる属性のキー + */ + private enum class AttributeKey(val key: String) { + + GAME_CONTEXT("x-game-context"), + } + + /** + * カスタムヘッダー名 + */ + private enum class HeaderName(val key: String) { + + CLIENT_VERSION("x-client-version"), + IDEMPOTENCY_KEY("x-idempotency-key"), + + // 以下はデバッグモードの場合のみ有効になる + SPOOFING_CURRENT_TIME("x-spoofing-current-time"), + } + + fun setGameContext(gctx: GameContext) { + httpServletRequest.setAttribute(AttributeKey.GAME_CONTEXT.key, gctx) + } + + @Throws(HttpMetadataNotFoundException::class) + fun getGameContext(): GameContext { + val gctx = httpServletRequest.getAttribute(AttributeKey.GAME_CONTEXT.key)?.let { it as GameContext } + if (gctx == null) { + throw HttpMetadataNotFoundException("Game context is not found in request scope.") + } + return gctx + } + + fun getClientVersion(): String? { + return httpServletRequest.getHeader(HeaderName.CLIENT_VERSION.key) + } + + fun getIdempotencyKey(): String? { + return httpServletRequest.getHeader(HeaderName.IDEMPOTENCY_KEY.key) + } + + fun getSpoofingCurrentTime(): LocalDateTime? { + return if (config.debug) { + httpServletRequest.getHeader(HeaderName.SPOOFING_CURRENT_TIME.key)?.let { + LocalDateTime.parse(it, DateTimeFormatter.ISO_DATE_TIME) + } + } else { + null + } + } +} + +class HttpMetadataNotFoundException(message: String) : Exception(message) diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/config/WebMvcConfig.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/config/WebMvcConfig.kt new file mode 100644 index 0000000..885a8a6 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/config/WebMvcConfig.kt @@ -0,0 +1,23 @@ +package net.averak.gsync.adapter.handler.rest.config + +import net.averak.gsync.adapter.handler.rest.interceptor.IRequestInterceptor +import net.averak.gsync.adapter.handler.rest.interceptor.InterceptorPriority +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer + +@Configuration +open class WebMvcConfig( + private val interceptors: List, +) : WebMvcConfigurer { + + override fun addInterceptors(registry: InterceptorRegistry) { + interceptors.forEach { + when (it.getPriority()) { + InterceptorPriority.HIGH -> registry.addInterceptor(it).order(0) + InterceptorPriority.MEDIUM -> registry.addInterceptor(it).order(1) + InterceptorPriority.LOW -> registry.addInterceptor(it).order(2) + } + } + } +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt new file mode 100644 index 0000000..64c9f6f --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt @@ -0,0 +1,48 @@ +package net.averak.gsync.adapter.handler.rest.interceptor + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import net.averak.gsync.adapter.handler.rest.HttpRequestScope +import net.averak.gsync.core.logger.Logger +import org.springframework.http.HttpStatusCode +import org.springframework.stereotype.Component +import org.springframework.web.servlet.ModelAndView +import java.time.Duration +import java.time.LocalDateTime + +/** + * アクセスログを出力するインターセプター + */ +@Component +class AccessLogInterceptor( + private val logger: Logger, + private val requestScope: HttpRequestScope, +) : IRequestInterceptor { + + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + return true + } + + override fun postHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any, modelAndView: ModelAndView?) { + val gctx = requestScope.getGameContext() + logger.info( + gctx, + "access log", + mapOf( + "http_request" to mapOf( + "client_version" to requestScope.getClientVersion(), + "idempotency_key" to gctx.idempotencyKey, + "requested_at" to gctx.currentTime.toString(), + "elapsed_ms" to Duration.between(gctx.currentTime, LocalDateTime.now()).toMillis(), + "status_code" to HttpStatusCode.valueOf(response.status), + "method" to request.method, + "path" to request.requestURI, + "query_string" to request.queryString, + "ip_address" to request.remoteAddr, + ), + ), + ) + } + + override fun getPriority(): InterceptorPriority = InterceptorPriority.LOW +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt new file mode 100644 index 0000000..92a8358 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt @@ -0,0 +1,40 @@ +package net.averak.gsync.adapter.handler.rest.interceptor + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import net.averak.gsync.adapter.handler.rest.HttpRequestScope +import net.averak.gsync.core.config.Config +import net.averak.gsync.core.daterange.Dateline +import net.averak.gsync.core.gamecontext.GameContext +import org.springframework.stereotype.Component +import org.springframework.web.servlet.ModelAndView +import java.time.LocalDateTime +import java.util.* + +@Component +open class GameContextInterceptor( + private val config: Config, + private val requestScope: HttpRequestScope, +) : IRequestInterceptor { + + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean { + val spoofingCurrentTime = requestScope.getSpoofingCurrentTime() + val gctx = GameContext( + config.version, + // クライアントが Idempotency-Key を必ず設定してくるとは限らないので、未設定の場合はサーバ側でユニークキーを発行し、毎回異なるリクエストとして扱う + requestScope.getIdempotencyKey() ?: UUID.randomUUID().toString(), + Dateline.DEFAULT, + spoofingCurrentTime ?: LocalDateTime.now(), + ) + requestScope.setGameContext(gctx) + return true + } + + override fun postHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any, modelAndView: ModelAndView?) { + return + } + + override fun getPriority(): InterceptorPriority { + return InterceptorPriority.HIGH + } +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/IRequestInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/IRequestInterceptor.kt new file mode 100644 index 0000000..a7ae941 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/IRequestInterceptor.kt @@ -0,0 +1,17 @@ +package net.averak.gsync.adapter.handler.rest.interceptor + +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.web.servlet.HandlerInterceptor +import org.springframework.web.servlet.ModelAndView + +interface IRequestInterceptor : HandlerInterceptor { + + @Throws(Exception::class) + override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean + + @Throws(Exception::class) + override fun postHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any, modelAndView: ModelAndView?) + + fun getPriority(): InterceptorPriority +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/InterceptorPriority.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/InterceptorPriority.kt new file mode 100644 index 0000000..bff9dd6 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/InterceptorPriority.kt @@ -0,0 +1,11 @@ +package net.averak.gsync.adapter.handler.rest.interceptor + +/** + * インターセプターの実行優先度 + */ +enum class InterceptorPriority { + + HIGH, + MEDIUM, + LOW, +} diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy new file mode 100644 index 0000000..48efb00 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy @@ -0,0 +1,159 @@ +package net.averak.gsync.adapter.handler.rest + +import net.averak.gsync.core.exception.GsyncException +import net.averak.gsync.infrastructure.json.JsonUtils +import net.averak.gsync.testkit.AbstractDatabaseSpec +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.MvcResult +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.setup.MockMvcBuilders +import org.springframework.util.MultiValueMap +import org.springframework.web.context.WebApplicationContext + +abstract class AbstractController_IT extends AbstractDatabaseSpec { + + private MockMvc mockMvc + + @Autowired + private WebApplicationContext webApplicationContext + + /** + * GET request + * + * @param path path + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder getRequest(final String path) { + return MockMvcRequestBuilders.get(path) + } + + /** + * POST request + * + * @param path path + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder postRequest(final String path) { + return MockMvcRequestBuilders.post(path) + } + + /** + * POST request (Form) + * + * @param path path + * @param params query params + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder postRequest(final String path, final MultiValueMap params) { + return MockMvcRequestBuilders.post(path) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .params(params) + } + + /** + * POST request (JSON) + * + * @param path path + * @param content request body + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder postRequest(final String path, final Object content) { + return MockMvcRequestBuilders.post(path) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(JsonUtils.encode(content)) + } + + /** + * PUT request (JSON) + * + * @param path path + * @param content request body + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder putRequest(final String path, final Object content) { + return MockMvcRequestBuilders.put(path) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(JsonUtils.encode(content)) + } + + /** + * DELETE request + * + * @param path path + * + * @return HTTP request builder + */ + MockHttpServletRequestBuilder deleteRequest(final String path) { + return MockMvcRequestBuilders.delete(path) + } + + /** + * Execute request + * + * @param request HTTP request builder + * @param status expected HTTP status + * + * @return MVC result + */ + MvcResult execute(final MockHttpServletRequestBuilder request, final HttpStatus status) { + final result = mockMvc.perform(request).andReturn() + + assert result.response.status == status.value() + return result + } + + /** + * Execute request / return response + * + * @param request HTTP request builder + * @param status expected HTTP status + * @param clazz response class + * + * @return response + */ + def T execute(final MockHttpServletRequestBuilder request, final HttpStatus status, final Class clazz) { + final result = mockMvc.perform(request).andReturn() + + assert result.response.status == status.value() + return JsonUtils.decode(result.getResponse().getContentAsString(), clazz) + } + + /** + * Execute request / verify exception + * + * @param request HTTP request builder + * @param exception expected exception + * + * @return error response + */ + GlobalRestControllerAdvice.ErrorResponse execute(final MockHttpServletRequestBuilder request, final GsyncException ex) { + final result = mockMvc.perform(request).andReturn() + + final response = JsonUtils.decode(result.response.contentAsString, GlobalRestControllerAdvice.ErrorResponse.class) + assert response.code == ex.errorCode.name() + assert response.message == ex.errorCode.summary + return response + } + + /** + * setup before test case + */ + void setup() { + this.mockMvc = MockMvcBuilders + .webAppContextSetup(this.webApplicationContext) + .addFilter(({ request, response, chain -> + response.setCharacterEncoding("UTF-8") + chain.doFilter(request, response) + })) + .build() + } +} diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy new file mode 100644 index 0000000..74d18f6 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy @@ -0,0 +1,16 @@ +package net.averak.gsync.adapter.handler.rest + +import net.averak.gsync.core.exception.ErrorCode +import net.averak.gsync.core.exception.GsyncException + +class GlobalRestControllerAdvice_IT extends AbstractController_IT { + + def "異常系 存在しないパスの場合はエラーを返す"() { + given: + final path = "/api/xxx" + + expect: + final request = this.getRequest(path) + execute(request, new GsyncException(ErrorCode.NOT_FOUND_API)) + } +} diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy new file mode 100644 index 0000000..52fcaf6 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy @@ -0,0 +1,16 @@ +package net.averak.gsync.adapter.handler.rest + +import org.springframework.http.HttpStatus + +class HealthCheckController_IT extends AbstractController_IT { + + // API PATH + static final String BASE_PATH = "/api/health" + static final String HEALTH_CHECK_PATH = BASE_PATH + + def "ヘルスチェックAPI: 正常系 200 OKを返す"() { + expect: + final request = this.getRequest(HEALTH_CHECK_PATH) + this.execute(request, HttpStatus.OK) + } +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/config/Config.kt b/app/core/src/main/kotlin/net/averak/gsync/core/config/Config.kt new file mode 100644 index 0000000..0d063a2 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/config/Config.kt @@ -0,0 +1,14 @@ +package net.averak.gsync.core.config + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.context.annotation.Configuration + +@Configuration +@ConfigurationProperties("gsync") +@Suppress("MemberVisibilityCanBePrivate") +open class Config { + + var version: String = "" + + var debug: Boolean = false +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt new file mode 100644 index 0000000..9d55f99 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt @@ -0,0 +1,28 @@ +package net.averak.gsync.core.daterange + +import java.time.LocalDate +import java.time.LocalDateTime + +/** + * サービス内の「o月x日」を表す時刻範囲オブジェクト + * + * [from, to) の半開区間のため to は含まない + */ +data class DateRange( + val date: LocalDate, + val from: LocalDateTime, + val to: LocalDateTime, +) { + + constructor(time: LocalDateTime) : this(Dateline.DEFAULT.getDateRangeAtTime(time)) + + private constructor(dateRange: DateRange) : this(dateRange.date, dateRange.from, dateRange.to) + + fun includes(time: LocalDateTime): Boolean { + return from <= time && time < to + } + + fun addDays(n: Int): DateRange { + return DateRange(date.plusDays(n.toLong()), from.plusDays(n.toLong()), to.plusDays(n.toLong())) + } +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt new file mode 100644 index 0000000..dd0d055 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt @@ -0,0 +1,77 @@ +package net.averak.gsync.core.daterange + +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime + +/** + * サービス内で日付が変更される境界線を表すオブジェクト (UTC) + * -12:00:00 から +12:00:00 までの値をとる + * + * 例) Dateline が 00:00:00 の場合 + * UTC => 2000-01-01 00:00:00 ~ 2000-01-02 00:00:00 + * JST => 2000-01-01 09:00:00 ~ 2000-01-02 09:00:00 + * + * 例) Dateline が +05:00:00 の場合 + * UTC => 2000-01-01 05:00:00 ~ 2000-01-02 05:00:00 + * JST => 2000-01-01 14:00:00 ~ 2000-01-02 14:00:00 + * + * 例) Dateline が -05:00:00 の場合 + * UTC => 1999-12-23 19:00:00 ~ 2000-01-01 19:00:00 + * JST => 2000-01-01 04:00:00 ~ 2000-01-02 04:00:00 + */ +data class Dateline( + val isMinus: Boolean, + val hour: Int, + val minute: Int, + val second: Int, +) { + + companion object { + + // 22:00 (JST) に注文を締め切ることから、デフォルトの日付変更境界線は -11:00:00 (UTC) とする + // JST だと -02:00:00 なので、UTC だと -02:00:00 - 09:00:00 = -11:00:00 になる + @JvmStatic + val DEFAULT = Dateline(true, 11, 0, 0) + } + + init { + require(LocalTime.of(hour, minute, second) <= LocalTime.of(12, 0, 0)) { + "time must be -12:00:00 ~ +12:00:00, but was $this" + } + } + + fun getDateRangeOnDate(on: LocalDate): DateRange { + var start = LocalDateTime.of(on, LocalTime.of(0, 0, 0)) + if (isMinus) { + start = start.minusHours(hour.toLong()) + start = start.minusMinutes(minute.toLong()) + start = start.minusSeconds(second.toLong()) + } else { + start = start.plusHours(hour.toLong()) + start = start.plusMinutes(minute.toLong()) + start = start.plusSeconds(second.toLong()) + } + val end = start.plusDays(1) + return DateRange(on, start, end) + } + + fun getDateRangeAtTime(at: LocalDateTime): DateRange { + val dateRange = getDateRangeOnDate(at.toLocalDate()) + if (dateRange.from.isAfter(at)) { + return getDateRangeOnDate(at.minusDays(1).toLocalDate()) + } + if (dateRange.to.isBefore(at)) { + return getDateRangeOnDate(at.plusDays(1).toLocalDate()) + } + return dateRange + } + + override fun toString(): String { + return if (isMinus) { + String.format("-%02d:%02d:%02d", hour, minute, second) + } else { + String.format("+%02d:%02d:%02d", hour, minute, second) + } + } +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/app/core/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt new file mode 100644 index 0000000..243a503 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt @@ -0,0 +1,6 @@ +package net.averak.gsync.core.exception + +enum class ErrorCode(val summary: String) { + UNKNOWN("Unknown error."), + NOT_FOUND_API("API not found."), +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/app/core/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt new file mode 100644 index 0000000..900b1c3 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt @@ -0,0 +1,10 @@ +package net.averak.gsync.core.exception + +class GsyncException(val errorCode: ErrorCode, causedBy: Throwable?) : RuntimeException(causedBy) { + + constructor(errorCode: ErrorCode) : this(errorCode, null) + + constructor(causedBy: Throwable?) : this(ErrorCode.UNKNOWN, causedBy) + + override val message: String = causedBy?.toString() ?: errorCode.name +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt b/app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt new file mode 100644 index 0000000..df9d9dd --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt @@ -0,0 +1,20 @@ +package net.averak.gsync.core.gamecontext + +import net.averak.gsync.core.daterange.DateRange +import net.averak.gsync.core.daterange.Dateline +import java.time.LocalDateTime + +/** + * 機能によらずアプリケーション横断的なコンテキスト + */ +data class GameContext( + val serverVersion: String, + val idempotencyKey: String, + val dateline: Dateline, + val currentTime: LocalDateTime, +) { + + fun getToday(): DateRange { + return dateline.getDateRangeAtTime(currentTime) + } +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt new file mode 100644 index 0000000..e584e18 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt @@ -0,0 +1,67 @@ +package net.averak.gsync.core.logger + +import net.averak.gsync.core.gamecontext.GameContext +import net.logstash.logback.argument.StructuredArgument +import net.logstash.logback.argument.StructuredArguments +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component + +@Component +class Logger { + + private val logger = LoggerFactory.getLogger(Logger::class.java) + + fun info(gctx: GameContext, message: String) { + this.logger.info( + message, + makeServerInfoPayload(gctx), + makeGameContextPayload(gctx), + ) + } + + fun info(gctx: GameContext, message: String, payload: Map) { + this.logger.info( + message, + makeServerInfoPayload(gctx), + makeGameContextPayload(gctx), + StructuredArguments.value("payload", payload), + ) + } + + fun warn(gctx: GameContext, exception: Exception) { + this.logger.warn( + exception.toString(), + makeServerInfoPayload(gctx), + makeGameContextPayload(gctx), + StructuredArguments.value("exception", exception), + ) + } + + fun error(gctx: GameContext, exception: Exception) { + this.logger.error( + exception.toString(), + makeServerInfoPayload(gctx), + makeGameContextPayload(gctx), + StructuredArguments.value("exception", exception), + ) + } + + private fun makeGameContextPayload(gctx: GameContext): StructuredArgument { + return StructuredArguments.value( + "game_context", + mapOf( + "idempotencyKey" to gctx.idempotencyKey, + "currentTime" to gctx.currentTime.toString(), + ), + ) + } + + private fun makeServerInfoPayload(gctx: GameContext): StructuredArgument { + return StructuredArguments.value( + "server", + mapOf( + "version" to gctx.serverVersion, + ), + ) + } +} diff --git a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/app/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt similarity index 53% rename from src/main/kotlin/net/averak/gsync/domain/model/Echo.kt rename to app/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt index 2740b67..942d587 100644 --- a/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt +++ b/app/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt @@ -1,16 +1,17 @@ package net.averak.gsync.domain.model -import net.averak.gsync.domain.primitive.common.ID import java.time.LocalDateTime +import java.util.* data class Echo( - val id: ID, + val id: UUID, val message: String, val timestamp: LocalDateTime, ) { - constructor(message: String) : this( - ID(), + + constructor(message: String, now: LocalDateTime) : this( + UUID.randomUUID(), message, - LocalDateTime.now(), + now, ) } diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonConfig.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonConfig.kt new file mode 100644 index 0000000..04c57fc --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonConfig.kt @@ -0,0 +1,30 @@ +package net.averak.gsync.infrastructure.json + +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import java.time.format.DateTimeFormatter + +@Configuration +open class JsonConfig { + + @Bean + @Order(Ordered.LOWEST_PRECEDENCE) + open fun jackson2ObjectMapperBuilderCustomizer(): Jackson2ObjectMapperBuilderCustomizer { + // LocalDate, LocalDateTime に対して application.yml の設定は適応されないので、ここでシリアライザを定義する必要がある + return Jackson2ObjectMapperBuilderCustomizer { builder -> + builder.serializers( + LocalDateSerializer( + DateTimeFormatter.ofPattern("yyyy-MM-dd"), + ), + LocalDateTimeSerializer( + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"), + ), + ) + } + } +} diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt new file mode 100644 index 0000000..504a12c --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt @@ -0,0 +1,40 @@ +package net.averak.gsync.infrastructure.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.module.kotlin.KotlinFeature +import com.fasterxml.jackson.module.kotlin.KotlinModule + +class JsonUtils { + companion object { + + private val objectMapper = ObjectMapper() + .registerModule( + KotlinModule.Builder() + .withReflectionCacheSize(512) + .configure(KotlinFeature.NullToEmptyCollection, false) + .configure(KotlinFeature.NullToEmptyMap, false) + .configure(KotlinFeature.NullIsSameAsDefault, false) + .configure(KotlinFeature.SingletonSupport, false) + .configure(KotlinFeature.StrictNullChecks, false) + .build(), + ) + .registerModule(JavaTimeModule()) + .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) + + @JvmStatic + fun encode(value: Any?): String { + return if (value == null) { + "{}" + } else { + objectMapper.writeValueAsString(value) + } + } + + @JvmStatic + fun decode(json: String, clazz: Class): T { + return objectMapper.readValue(json, clazz) + } + } +} diff --git a/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy b/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy new file mode 100644 index 0000000..ec6cffb --- /dev/null +++ b/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy @@ -0,0 +1,46 @@ +package net.averak.gsync.infrastructure.json + +import net.averak.gsync.testkit.AbstractSpec + +class JsonUtils_UT extends AbstractSpec { + + static class Value { + + public String string + + public Integer integer + + Value(String string, Integer integer) { + this.string = string + this.integer = integer + } + + // JSON をデシリアライズする際に引数を持たないコンストラクタが必要になる + @SuppressWarnings('unused') + Value() {} + + } + + def "encode: オブジェクトを json 文字列に変換できる"() { + when: + final result = JsonUtils.encode(value) + + then: + // JSON はフィールドの順番を保証しないので、フィールド順が期待値と異なるかもしれない + result == expectedResult + + where: + value || expectedResult + new Value("hello", 100) || "{\"string\":\"hello\",\"integer\":100}" + null || "{}" + } + + def "decode: json 文字列をオブジェクトに変換できる"() { + when: + final result = JsonUtils.decode("{\"string\":\"hello\",\"integer\":100}", Value.class) + + then: + result.string == "hello" + result.integer == 100 + } +} diff --git a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt new file mode 100644 index 0000000..2cbbc4c --- /dev/null +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -0,0 +1,13 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.domain.model.Echo +import org.springframework.stereotype.Service + +@Service +class EchoUsecase { + + fun echo(gctx: GameContext, message: String): Echo { + return Echo(message, gctx.currentTime) + } +} diff --git a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy similarity index 67% rename from src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy rename to app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy index fa65aa0..d4ea003 100644 --- a/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -1,6 +1,6 @@ package net.averak.gsync.usecase -import net.averak.gsync.AbstractSpec +import net.averak.gsync.testkit.AbstractSpec abstract class AbstractUsecase_UT extends AbstractSpec { } diff --git a/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy new file mode 100644 index 0000000..ee7c736 --- /dev/null +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -0,0 +1,24 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.testkit.Faker +import org.springframework.beans.factory.annotation.Autowired + +class EchoUsecase_UT extends AbstractUsecase_UT { + + @Autowired + EchoUsecase sut + + def "echo: 正常系 Echoを作成できる"() { + given: + final gctx = Faker.fake(GameContext) + final message = Faker.alphanumeric() + + when: + final result = this.sut.echo(gctx, message) + + then: + result.timestamp == gctx.currentTime + result.message == message + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 1ca1461..c8e769d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import java.io.ByteArrayOutputStream + plugins { kotlin("jvm") version libs.versions.kotlin @@ -6,117 +8,207 @@ plugins { alias(libs.plugins.gradle.git.properties) alias(libs.plugins.spotless) alias(libs.plugins.sonarqube) - alias(libs.plugins.quarkus) - application - java groovy jacoco - eclipse - idea } -repositories { - mavenCentral() - gradlePluginPortal() +buildscript { + dependencies { + classpath(libs.spring.boot.gradle.plugin) + } } -dependencies { - // Quarkus - implementation(enforcedPlatform(libs.quarkus.bom)) - implementation(libs.quarkus.kotlin) - implementation(libs.quarkus.resteasy.reactive) - implementation(libs.quarkus.resteasy.reactive.jackson) - implementation(libs.quarkus.hibernate.orm) - implementation(libs.quarkus.arc) - implementation(libs.quarkus.config.yaml) - implementation(libs.quarkus.logging.json) - implementation(libs.kotlin.stdlib.jdk8) - - // GCP - implementation(libs.quarkus.google.cloud.spanner) { - modules { - module("com.google.guava:listenablefuture") { - replacedBy("com.google.guava:guava", "listenablefuture is part of guava") +allprojects { + group = "net.averak.gsync" + + repositories { + mavenCentral() + gradlePluginPortal() + } + + apply { + plugin("java") + plugin("kotlin") + plugin("groovy") + plugin("jacoco") + plugin(rootProject.libs.plugins.spotless.get().pluginId) + plugin(rootProject.libs.plugins.sonarqube.get().pluginId) + } + + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlin { + jvmToolchain { + languageVersion = JavaLanguageVersion.of(17) + } + + sourceSets { + all { + languageSettings { + languageVersion = "2.0" + } } } } - // Other utils - implementation(libs.guava) - implementation(libs.jackson.module.kotlin) + spotless { + kotlin { + targetExclude("build/**") + ktlint() + .setEditorConfigPath("$rootDir/.editorconfig") + .editorConfigOverride( + mapOf( + // .editorconfig のルール無効化設定を読み込んでくれないので、再度設定する必要がある + "ktlint_standard_no-wildcard-imports" to "disabled", + "ktlint_standard_package-name" to "disabled", + "ktlint_standard_max-line-length" to "disabled", + ), + ) + } - // Test Framework & utils - testImplementation(project(":testkit")) - testImplementation(libs.quarkus.junit5) - testImplementation(libs.spock.core) - testImplementation(libs.groovy) - testImplementation(libs.groovy.sql) - testImplementation(libs.easy.random) -} + groovy { + targetExclude("build/**") + } + } + + sonar { + properties { + property("sonar.projectKey", "averak_gsync") + property("sonar.organization", "averak") + property("sonar.host.url", "https://sonarcloud.io") + property("sonar.exclusions", "testkit/**") + } + } + + tasks { + test { + useJUnitPlatform() + } -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) + processResources { + dependsOn(":generateGitProperties") + } + + processTestResources { + dependsOn(":generateGitProperties") + } + + jacocoTestReport { + reports { + xml.required = true + csv.required = true + } + } } } -kotlin { +subprojects { sourceSets { - all { - languageSettings { - languageVersion = "2.0" - } + test { + resources.srcDir("$rootDir/src/test/resources") } } + + dependencies { + implementation(rootProject.libs.spring.boot.starter) + implementation(rootProject.libs.guava) + testImplementation(project(":testkit")) + } } -spotless { - kotlin { - targetExclude("build/**") - ktlint() - .setEditorConfigPath("$rootDir/.editorconfig") - .editorConfigOverride( - mapOf( - // .editorconfig のルール無効化設定を読み込んでくれないので、再度設定する必要がある - "ktlint_standard_no-wildcard-imports" to "disabled", - "ktlint_standard_package-name" to "disabled", - "ktlint_standard_max-line-length" to "disabled", - ), - ) - } - - groovy { - targetExclude("build/**") +project(":adapter") { + dependencies { + implementation(project(":core")) + implementation(project(":domain")) + implementation(project(":infrastructure")) + implementation(project(":usecase")) + implementation(rootProject.libs.spring.boot.starter.web) + implementation(rootProject.libs.spring.boot.starter.webflux) + + testImplementation(rootProject.libs.spring.boot.starter.test) } } -sonar { - properties { - property("sonar.projectKey", "averak_gsync") - property("sonar.organization", "averak") - property("sonar.host.url", "https://sonarcloud.io") - property("sonar.exclusions", "testkit/**") +project(":core") { + dependencies { + implementation(rootProject.libs.logback.classic) + implementation(rootProject.libs.logstash.logback.encoder) } } -tasks { - test { - useJUnitPlatform() +project(":domain") { + dependencies { + implementation(project(":core")) + implementation(rootProject.libs.spring.boot.starter) } +} + +project(":infrastructure") { + dependencies { + implementation(rootProject.libs.spring.boot.starter.web) + implementation(rootProject.libs.spring.boot.starter.webflux) + implementation(rootProject.libs.jackson.module.kotlin) + implementation(rootProject.libs.jackson.datatype.jsr310) + } +} - jar { - enabled = false +project(":usecase") { + dependencies { + implementation(project(":core")) + implementation(project(":domain")) } +} - javadoc { - (options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:none", true) +project(":testkit") { + dependencies { + implementation(rootProject) + implementation(project(":core")) + implementation(project(":adapter")) + implementation(project(":domain")) + implementation(project(":infrastructure")) + implementation(project(":usecase")) + implementation(rootProject.libs.spring.boot.starter.test) + implementation(rootProject.libs.spring.boot.starter.data.jpa) + implementation(rootProject.libs.spring.boot.starter.data.redis) + implementation(rootProject.libs.commons.lang3) + + api(rootProject.libs.spock.core) + api(rootProject.libs.spock.spring) + api(rootProject.libs.groovy.sql) + api(rootProject.libs.easy.random) } - jacocoTestReport { - reports { - xml.required = true - csv.required = true + tasks { + compileGroovy { + dependsOn("compileKotlin") + classpath += files("build/classes/kotlin/main") } } } + +dependencies { + implementation(project(":adapter")) + implementation(project(":core")) + implementation(project(":domain")) + implementation(project(":infrastructure")) + implementation(project(":usecase")) + implementation(libs.spring.boot.starter) + implementation(libs.google.cloud.spanner.spring) + implementation(libs.google.cloud.spanner.jdbc) +} + +gitProperties { + val stdout = ByteArrayOutputStream() + project.exec { + commandLine("git", "describe", "--tags", "--abbrev=1") + standardOutput = stdout + isIgnoreExitValue = true + } + val version = stdout.toString().trim().replaceFirst("^v", "") + customProperty("git.commit.id.describe", version) + gitPropertiesResourceDir = file("$rootDir/build/git/src/main/resources") +} diff --git a/compose.yaml b/compose.yaml index be951a2..eb97555 100644 --- a/compose.yaml +++ b/compose.yaml @@ -19,7 +19,7 @@ services: PROJECT_ID: "gsync-sandbox" SPANNER_EMULATOR_URL: "http://spanner-emulator:9020/" INSTANCE_NAME: "sandbox" - DATABASE_NAME: "local" + DATABASE_NAME: "sandbox" redis: image: redis:7.0 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f4a395..730bc5c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ easy-random = "6.2.1" groovy = "4.0.7" kotlin = "1.9.20" -quarkus = "3.5.0" spock = "2.4-M1-groovy-4.0" +spring-boot = "3.2.1" [libraries] commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" @@ -11,23 +11,24 @@ easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "eas groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } guava = "com.google.guava:guava:32.1.3-jre" -jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16.0-rc1" -kotlin-stdlib-jdk8 = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } -quarkus-arc = { module = "io.quarkus:quarkus-arc", version.ref = "quarkus" } -quarkus-bom = { module = "io.quarkus.platform:quarkus-bom", version.ref = "quarkus" } -quarkus-config-yaml = { module = "io.quarkus:quarkus-config-yaml", version.ref = "quarkus" } -quarkus-google-cloud-spanner = "io.quarkiverse.googlecloudservices:quarkus-google-cloud-spanner:2.6.0" -quarkus-hibernate-orm = { module = "io.quarkus:quarkus-hibernate-orm", version.ref = "quarkus" } -quarkus-junit5 = { module = "io.quarkus:quarkus-junit5", version.ref = "quarkus" } -quarkus-kotlin = { module = "io.quarkus:quarkus-kotlin", version.ref = "quarkus" } -quarkus-logging-json = { module = "io.quarkus:quarkus-logging-json", version.ref = "quarkus" } -quarkus-resteasy-reactive = { module = "io.quarkus:quarkus-resteasy-reactive", version.ref = "quarkus" } -quarkus-resteasy-reactive-jackson = { module = "io.quarkus:quarkus-resteasy-reactive-jackson", version.ref = "quarkus" } +jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1" +jackson-datatype-jsr310 = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1" +logback-classic = "ch.qos.logback:logback-classic:1.4.14" +logstash-logback-encoder = "net.logstash.logback:logstash-logback-encoder:7.4" spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } +spock-spring = { module = "org.spockframework:spock-spring", version.ref = "spock" } +spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" } +spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter", version.ref = "spring-boot" } +spring-boot-starter-data-jpa = { module = "org.springframework.boot:spring-boot-starter-data-jpa", version.ref = "spring-boot" } +spring-boot-starter-data-redis = { module = "org.springframework.boot:spring-boot-starter-data-redis", version.ref = "spring-boot" } +spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } +spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } +spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot" } +google-cloud-spanner-spring = "com.google.cloud:spring-cloud-gcp-starter-data-spanner:5.0.0" +google-cloud-spanner-jdbc = "com.google.cloud:google-cloud-spanner-jdbc:2.15.0" [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" -quarkus = { id = "io.quarkus", version.ref = "quarkus" } sonarqube = "org.sonarqube:4.4.1.3373" spotless = "com.diffplug.spotless:6.22.0" version-catalog-update = "nl.littlerobots.version-catalog-update:0.8.1" diff --git a/settings.gradle b/settings.gradle index 33b12cc..9a30d66 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,14 @@ rootProject.name = "gsync" + +include(":adapter") +include(":core") +include(":domain") +include(":infrastructure") +include(":usecase") include(":testkit") + +project(":adapter").projectDir = file("app/adapter") +project(":core").projectDir = file("app/core") +project(":domain").projectDir = file("app/domain") +project(":infrastructure").projectDir = file("app/infrastructure") +project(":usecase").projectDir = file("app/usecase") diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm deleted file mode 100644 index 72997c5..0000000 --- a/src/main/docker/Dockerfile.jvm +++ /dev/null @@ -1,97 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode -# -# Before building the container image run: -# -# ./gradlew build -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/gsync-jvm . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/gsync-jvm -# -# If you want to include the debug port into your docker image -# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. -# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 -# when running the container -# -# Then run the container using : -# -# docker run -i --rm -p 8080:8080 quarkus/gsync-jvm -# -# This image uses the `run-java.sh` script to run the application. -# This scripts computes the command line to execute your Java application, and -# includes memory/GC tuning. -# You can configure the behavior using the following environment properties: -# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") -# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options -# in JAVA_OPTS (example: "-Dsome.property=foo") -# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is -# used to calculate a default maximal heap memory based on a containers restriction. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio -# of the container available memory as set here. The default is `50` which means 50% -# of the available memory is used as an upper boundary. You can skip this mechanism by -# setting this value to `0` in which case no `-Xmx` option is added. -# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This -# is used to calculate a default initial heap memory based on the maximum heap memory. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio -# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` -# is used as the initial heap size. You can skip this mechanism by setting this value -# to `0` in which case no `-Xms` option is added (example: "25") -# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. -# This is used to calculate the maximum value of the initial heap memory. If used in -# a container without any memory constraints for the container then this option has -# no effect. If there is a memory constraint then `-Xms` is limited to the value set -# here. The default is 4096MB which means the calculated value of `-Xms` never will -# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") -# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output -# when things are happening. This option, if set to true, will set -# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). -# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: -# true"). -# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). -# - CONTAINER_CORE_LIMIT: A calculated core limit as described in -# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") -# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). -# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. -# (example: "20") -# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. -# (example: "40") -# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. -# (example: "4") -# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus -# previous GC times. (example: "90") -# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") -# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") -# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should -# contain the necessary JRE command-line options to specify the required GC, which -# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). -# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") -# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") -# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be -# accessed directly. (example: "foo.example.com,bar.example.com") -# -### -FROM registry.access.redhat.com/ubi8/openjdk-17:1.16 - -ENV LANGUAGE='en_US:en' - - -# We make four distinct layers so if there are application changes the library layers can be re-used -COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ -COPY --chown=185 build/quarkus-app/*.jar /deployments/ -COPY --chown=185 build/quarkus-app/app/ /deployments/app/ -COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ - -EXPOSE 8080 -USER 185 -ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" -ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" - -ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] - diff --git a/src/main/docker/Dockerfile.legacy-jar b/src/main/docker/Dockerfile.legacy-jar deleted file mode 100644 index c3b46dc..0000000 --- a/src/main/docker/Dockerfile.legacy-jar +++ /dev/null @@ -1,93 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode -# -# Before building the container image run: -# -# ./gradlew build -Dquarkus.package.type=legacy-jar -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/gsync-legacy-jar . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/gsync-legacy-jar -# -# If you want to include the debug port into your docker image -# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. -# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 -# when running the container -# -# Then run the container using : -# -# docker run -i --rm -p 8080:8080 quarkus/gsync-legacy-jar -# -# This image uses the `run-java.sh` script to run the application. -# This scripts computes the command line to execute your Java application, and -# includes memory/GC tuning. -# You can configure the behavior using the following environment properties: -# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") -# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options -# in JAVA_OPTS (example: "-Dsome.property=foo") -# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is -# used to calculate a default maximal heap memory based on a containers restriction. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio -# of the container available memory as set here. The default is `50` which means 50% -# of the available memory is used as an upper boundary. You can skip this mechanism by -# setting this value to `0` in which case no `-Xmx` option is added. -# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This -# is used to calculate a default initial heap memory based on the maximum heap memory. -# If used in a container without any memory constraints for the container then this -# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio -# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` -# is used as the initial heap size. You can skip this mechanism by setting this value -# to `0` in which case no `-Xms` option is added (example: "25") -# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. -# This is used to calculate the maximum value of the initial heap memory. If used in -# a container without any memory constraints for the container then this option has -# no effect. If there is a memory constraint then `-Xms` is limited to the value set -# here. The default is 4096MB which means the calculated value of `-Xms` never will -# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") -# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output -# when things are happening. This option, if set to true, will set -# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). -# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: -# true"). -# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). -# - CONTAINER_CORE_LIMIT: A calculated core limit as described in -# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") -# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). -# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. -# (example: "20") -# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. -# (example: "40") -# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. -# (example: "4") -# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus -# previous GC times. (example: "90") -# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") -# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") -# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should -# contain the necessary JRE command-line options to specify the required GC, which -# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). -# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") -# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") -# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be -# accessed directly. (example: "foo.example.com,bar.example.com") -# -### -FROM registry.access.redhat.com/ubi8/openjdk-17:1.16 - -ENV LANGUAGE='en_US:en' - - -COPY build/lib/* /deployments/lib/ -COPY build/*-runner.jar /deployments/quarkus-run.jar - -EXPOSE 8080 -USER 185 -ENV JAVA_OPTS="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" -ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" - -ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native deleted file mode 100644 index 1cb8015..0000000 --- a/src/main/docker/Dockerfile.native +++ /dev/null @@ -1,27 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. -# -# Before building the container image run: -# -# ./gradlew build -Dquarkus.package.type=native -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.native -t quarkus/gsync . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/gsync -# -### -FROM registry.access.redhat.com/ubi8/ubi-minimal:8.8 -WORKDIR /work/ -RUN chown 1001 /work \ - && chmod "g+rwX" /work \ - && chown 1001:root /work -COPY --chown=1001:root build/*-runner /work/application - -EXPOSE 8080 -USER 1001 - -ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/docker/Dockerfile.native-micro b/src/main/docker/Dockerfile.native-micro deleted file mode 100644 index 1204fe4..0000000 --- a/src/main/docker/Dockerfile.native-micro +++ /dev/null @@ -1,30 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. -# It uses a micro base image, tuned for Quarkus native executables. -# It reduces the size of the resulting container image. -# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. -# -# Before building the container image run: -# -# ./gradlew build -Dquarkus.package.type=native -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/gsync . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/gsync -# -### -FROM quay.io/quarkus/quarkus-micro-image:2.0 -WORKDIR /work/ -RUN chown 1001 /work \ - && chmod "g+rwX" /work \ - && chown 1001:root /work -COPY --chown=1001:root build/*-runner /work/application - -EXPOSE 8080 -USER 1001 - -ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/kotlin/net/averak/gsync/Entrypoint.kt b/src/main/kotlin/net/averak/gsync/Entrypoint.kt new file mode 100644 index 0000000..b9eb9fb --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/Entrypoint.kt @@ -0,0 +1,25 @@ +package net.averak.gsync + +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.annotation.AnnotationBeanNameGenerator +import org.springframework.context.annotation.ComponentScan +import java.util.* + +@SpringBootApplication +@ComponentScan(basePackages = ["net.averak.gsync"], nameGenerator = Entrypoint.FQCNBeanNameGenerator::class) +open class Entrypoint { + + class FQCNBeanNameGenerator : AnnotationBeanNameGenerator() { + + override fun buildDefaultBeanName(definition: BeanDefinition): String { + return definition.beanClassName!! + } + } +} + +fun main(args: Array) { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + runApplication(*args) +} diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt deleted file mode 100644 index 33810c0..0000000 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/controller/HealthCheckController.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.averak.gsync.adapter.handler.controller - -import jakarta.ws.rs.GET -import jakarta.ws.rs.Path -import org.jboss.resteasy.reactive.RestResponse - -@Path("/api/health") -class HealthCheckController { - @GET - fun health(): RestResponse { - return RestResponse.ok() - } -} diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt deleted file mode 100644 index b92c98b..0000000 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/ErrorResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package net.averak.gsync.adapter.handler.exception - -import net.averak.gsync.core.exception.ErrorCode - -data class ErrorResponse( - val code: String, - val summary: String, -) { - constructor(errorCode: ErrorCode) : this(errorCode.name, errorCode.summary) -} diff --git a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt b/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt deleted file mode 100644 index 6576c32..0000000 --- a/src/main/kotlin/net/averak/gsync/adapter/handler/exception/NotFoundExceptionMapper.kt +++ /dev/null @@ -1,19 +0,0 @@ -package net.averak.gsync.adapter.handler.exception - -import jakarta.ws.rs.NotFoundException -import jakarta.ws.rs.core.MediaType -import jakarta.ws.rs.core.Response -import jakarta.ws.rs.ext.ExceptionMapper -import jakarta.ws.rs.ext.Provider -import net.averak.gsync.core.exception.ErrorCode - -@Provider -class NotFoundExceptionMapper : ExceptionMapper { - override fun toResponse(exception: NotFoundException?): Response { - return Response - .status(Response.Status.NOT_FOUND) - .type(MediaType.APPLICATION_JSON_TYPE) - .entity(ErrorResponse(ErrorCode.NOT_FOUND_API)) - .build() - } -} diff --git a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt b/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt deleted file mode 100644 index 6501591..0000000 --- a/src/main/kotlin/net/averak/gsync/core/exception/ErrorCode.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.averak.gsync.core.exception - -enum class ErrorCode(val summary: String) { - // 400 Bad Request - VALIDATION_ERROR("Request validation exception was thrown."), - INVALID_REQUEST_PARAMETERS("Request parameters is invalid."), - ID_FORMAT_IS_INVALID("ID format is invalid."), - - // 404 Not Found - NOT_FOUND_API("API not found."), -} diff --git a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt b/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt deleted file mode 100644 index a269e3d..0000000 --- a/src/main/kotlin/net/averak/gsync/core/exception/GsyncException.kt +++ /dev/null @@ -1,5 +0,0 @@ -package net.averak.gsync.core.exception - -class GsyncException(val errorCode: ErrorCode, val causedBy: Throwable?) : RuntimeException(errorCode.summary, causedBy) { - constructor(errorCode: ErrorCode) : this(errorCode, null) -} diff --git a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt b/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt deleted file mode 100644 index ef22892..0000000 --- a/src/main/kotlin/net/averak/gsync/domain/primitive/common/ID.kt +++ /dev/null @@ -1,17 +0,0 @@ -package net.averak.gsync.domain.primitive.common - -import net.averak.gsync.core.exception.ErrorCode -import net.averak.gsync.core.exception.GsyncException -import java.util.UUID - -data class ID(val value: String) { - init { - try { - UUID.fromString(value) - } catch (_: IllegalArgumentException) { - throw GsyncException(ErrorCode.ID_FORMAT_IS_INVALID) - } - } - - constructor() : this(UUID.randomUUID().toString()) -} diff --git a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt b/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt deleted file mode 100644 index d67329e..0000000 --- a/src/main/kotlin/net/averak/gsync/infrastructure/json/JsonUtils.kt +++ /dev/null @@ -1,40 +0,0 @@ -package net.averak.gsync.infrastructure.json - -import com.fasterxml.jackson.core.JsonParser -import com.fasterxml.jackson.databind.ObjectMapper -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.fasterxml.jackson.module.kotlin.KotlinFeature -import com.fasterxml.jackson.module.kotlin.KotlinModule - -class JsonUtils { - companion object { - private val objectMapper = - ObjectMapper() - .registerModule( - KotlinModule.Builder() - .withReflectionCacheSize(512) - .configure(KotlinFeature.NullToEmptyCollection, false) - .configure(KotlinFeature.NullToEmptyMap, false) - .configure(KotlinFeature.NullIsSameAsDefault, false) - .configure(KotlinFeature.SingletonSupport, false) - .configure(KotlinFeature.StrictNullChecks, false) - .build(), - ) - .registerModule(JavaTimeModule()) - .configure(JsonParser.Feature.AUTO_CLOSE_SOURCE, true) - - @JvmStatic - fun encode(value: Any?): String { - return if (value == null) { - "{}" - } else { - objectMapper.writeValueAsString(value) - } - } - - @JvmStatic - fun decode(json: String, clazz: Class): T { - return objectMapper.readValue(json, clazz) - } - } -} diff --git a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt deleted file mode 100644 index 390de61..0000000 --- a/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt +++ /dev/null @@ -1,11 +0,0 @@ -package net.averak.gsync.usecase - -import jakarta.inject.Singleton -import net.averak.gsync.domain.model.Echo - -@Singleton -class EchoUsecase { - fun echo(message: String): Echo { - return Echo(message) - } -} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9a9ace7..039f1a8 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,26 +1,40 @@ -quarkus: - application: - name: "gsync" - version: "1.0.0-SNAPSHOT" - log: - console: - json: true - google: - cloud: - project-id: ${GOOGLE_CLOUD_PROJECT_ID:gsync} - instance-id: ${GOOGLE_CLOUD_SPANNER_INSTANCE_ID:gsync} - database-id: ${GOOGLE_CLOUD_SPANNER_DATABASE_NAME:gsync} +spring: + config: + import: + - classpath:/git.properties + cloud: + gcp: spanner: - emulator-host: http://localhost:9020 + project-id: ${GCP_PROJECT_ID:gsync-sandbox} + instance-id: ${GCP_SPANNER_INSTANCE_ID:sandbox} + database: ${GCP_SPANNER_DATABASE:sandbox} + emulator-host: ${SPANNER_EMULATOR_HOST:localhost:9010} + datasource: + url: jdbc:cloudspanner:/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database} + driver-class-name: com.google.cloud.spanner.jdbc.JdbcDriver + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: com.google.cloud.spanner.hibernate.SpannerDialect + show_sql: true + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + database: 0 + jackson: + date-format: yyyy-MM-dd'T'HH:mm:ss'Z' + time-zone: UTC +server: + port: ${PORT:8080} + servlet: + encoding: + charset: UTF-8 + force: true -"%dev": - quarkus: - log: - console: - json: false - -"%test": - quarkus: - log: - console: - json: false +gsync: + version: ${git.commit.id.describe} + debug: ${IS_DEBUG:false} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..30081ee --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + + + + ${TIME_ZONE} + + timestamp + logger + [ignore] + [ignore] + [ignore] + + ${SEPARATOR} + UTF-8 + + + + + + + diff --git a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy b/src/test/groovy/net/averak/gsync/AbstractSpec.groovy deleted file mode 100644 index fbc18b6..0000000 --- a/src/test/groovy/net/averak/gsync/AbstractSpec.groovy +++ /dev/null @@ -1,31 +0,0 @@ -package net.averak.gsync - -import io.quarkus.arc.All -import io.quarkus.test.junit.QuarkusTest -import jakarta.annotation.PostConstruct -import jakarta.inject.Inject -import net.averak.gsync.core.exception.GsyncException -import net.averak.gsync.testkit.Faker -import net.averak.gsync.testkit.IRandomizer -import spock.lang.Specification - -@QuarkusTest -abstract class AbstractSpec extends Specification { - - @Inject - @All - List randomizers - - @PostConstruct - void init() { - Faker.init(randomizers) - } - - /** - * 例外を検証 - */ - static void verify(final GsyncException actual, final GsyncException expected) { - assert actual.errorCode == expected.errorCode - } - -} diff --git a/src/test/groovy/net/averak/gsync/TestConfig.groovy b/src/test/groovy/net/averak/gsync/TestConfig.groovy deleted file mode 100644 index ecfdee3..0000000 --- a/src/test/groovy/net/averak/gsync/TestConfig.groovy +++ /dev/null @@ -1,7 +0,0 @@ -package net.averak.gsync - -import jakarta.enterprise.context.ApplicationScoped - -@ApplicationScoped -class TestConfig { -} diff --git a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy b/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy deleted file mode 100644 index ca0759d..0000000 --- a/src/test/groovy/net/averak/gsync/adapter/handler/controller/AbstractController_IT.groovy +++ /dev/null @@ -1,6 +0,0 @@ -package net.averak.gsync.adapter.handler.controller - -import net.averak.gsync.AbstractSpec - -abstract class AbstractController_IT extends AbstractSpec { -} diff --git a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy b/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy deleted file mode 100644 index 04e1b45..0000000 --- a/src/test/groovy/net/averak/gsync/domain/primitive/common/ID_UT.groovy +++ /dev/null @@ -1,39 +0,0 @@ -package net.averak.gsync.domain.primitive.common - -import net.averak.gsync.AbstractSpec -import net.averak.gsync.core.exception.ErrorCode -import net.averak.gsync.core.exception.GsyncException -import net.averak.gsync.testkit.Faker - -class ID_UT extends AbstractSpec { - - def "constructor: 正常に作成できる"() { - when: - new ID(value) - - then: - noExceptionThrown() - - where: - value << [ - Faker.uuidv4(), - Faker.uuidv5("1"), - ] - } - - def "ID: 制約違反の場合は例外を返す"() { - when: - new ID(value) - - then: - final exception = thrown(GsyncException) - verify(exception, new GsyncException(ErrorCode.ID_FORMAT_IS_INVALID)) - - where: - value << [ - Faker.alphanumeric(36), - Faker.uuidv4() + "A", - ] - } - -} \ No newline at end of file diff --git a/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy b/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy deleted file mode 100644 index 373c919..0000000 --- a/src/test/groovy/net/averak/gsync/infrastructure/json/JsonUtils_UT.groovy +++ /dev/null @@ -1,47 +0,0 @@ -package net.averak.gsync.infrastructure.json - -import net.averak.gsync.AbstractSpec - -class JsonUtils_UT extends AbstractSpec { - - def "encode: JSON 文字列にエンコードする"() { - when: - final result = JsonUtils.encode(value) - - then: - result == expectedResult - - where: - value || expectedResult - new SampleValue(100, "hello") || "{\"intValue\":100,\"stringValue\":\"hello\"}" - null || "{}" - } - - def "decode: JSON 文字列をデコードする"() { - given: - final json = "{\"intValue\":100,\"stringValue\":\"hello\"}" - - when: - final result = JsonUtils.decode(json, SampleValue.class) - - then: - result.intValue == 100 - result.stringValue == "hello" - } - - static class SampleValue { - - public Integer intValue - - public String stringValue - - SampleValue() {} - - SampleValue(Integer intValue, String stringValue) { - this.intValue = intValue - this.stringValue = stringValue - } - - } - -} diff --git a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy b/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy deleted file mode 100644 index 3bf8e90..0000000 --- a/src/test/groovy/net/averak/gsync/randomizer/domain/primitive/common/IDRandomizer.groovy +++ /dev/null @@ -1,16 +0,0 @@ -package net.averak.gsync.randomizer.domain.primitive.common - -import net.averak.gsync.domain.primitive.common.ID -import net.averak.gsync.testkit.IRandomizer - -@Singleton -class IDRandomizer implements IRandomizer { - - final Class typeToGenerate = ID.class - - @Override - Object getRandomValue() { - return new ID() - } - -} diff --git a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy deleted file mode 100644 index a974b1d..0000000 --- a/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy +++ /dev/null @@ -1,21 +0,0 @@ -package net.averak.gsync.usecase - -import jakarta.inject.Inject -import net.averak.gsync.testkit.Faker - -class EchoUsecase_Echo_UT extends AbstractUsecase_UT { - - @Inject - EchoUsecase sut - - def "echo: 正常系 Echoを作成できる"() { - given: - final message = Faker.alphanumeric() - - when: - final result = sut.echo(message) - - then: - result.message == message - } -} diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..5a1d3a1 --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,7 @@ +spring: + data: + redis: + database: 1 + +gsync: + debug: true diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..e4d6095 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/testkit/build.gradle.kts b/testkit/build.gradle.kts deleted file mode 100644 index 19bc9e2..0000000 --- a/testkit/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -plugins { - kotlin("jvm") version libs.versions.kotlin -} - -repositories { - mavenCentral() -} - -dependencies { - implementation(libs.groovy.sql) - implementation(libs.easy.random) - implementation(libs.commons.lang3) - implementation(libs.guava) -} - -kotlin { - sourceSets { - all { - languageSettings { - languageVersion = "2.0" - } - } - } -} diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy new file mode 100644 index 0000000..9a4e570 --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy @@ -0,0 +1,23 @@ +package net.averak.gsync.testkit + +import groovy.sql.Sql +import jakarta.annotation.PostConstruct +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.transaction.annotation.Transactional + +// @Transactional を付けるとテストケースがトランザクション内で実行され、テストケース実行後にロールバックされる +// https://spring.pleiades.io/spring-framework/reference/testing/testcontext-framework/tx.html#testcontext-tx-enabling-transactions +@Transactional +@EnableAutoConfiguration +abstract class AbstractDatabaseSpec extends AbstractSpec { + + @Autowired + Sql sql + + @PostConstruct + private void init() { + Fixture.init(sql) + } + +} diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractSpec.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractSpec.groovy new file mode 100644 index 0000000..03f1e49 --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractSpec.groovy @@ -0,0 +1,13 @@ +package net.averak.gsync.testkit + +import net.averak.gsync.Entrypoint +import org.spockframework.spring.EnableSharedInjection +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ActiveProfiles +import spock.lang.Specification + +@SpringBootTest(classes = [Entrypoint]) +@ActiveProfiles("test") +@EnableSharedInjection +abstract class AbstractSpec extends Specification { +} diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy new file mode 100644 index 0000000..1433ccb --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy @@ -0,0 +1,30 @@ +package net.averak.gsync.testkit + +import net.averak.gsync.core.exception.GsyncException + +import java.time.Duration +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit + +class Assert { + + static void exceptionIs(final GsyncException actual, final GsyncException expected) { + assert expected.class == actual.class + assert expected.errorCode == actual.errorCode + } + + static void exceptionIs(final Exception actual, final Exception expected) { + assert expected.class == actual.class + assert expected.message == actual.message + } + + /** + * LocalDateTime が一致するか判定 + * + * @param approxDuration 許容する誤差 + */ + static void localDateTimeIs(final LocalDateTime actual, final LocalDateTime expected, final Duration approxDuration = Duration.ofSeconds(5)) { + assert ChronoUnit.MILLIS.between(actual as LocalDateTime, expected) <= approxDuration.toMillis() + } + +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt index 3ccafcd..9ebd65a 100644 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -3,7 +3,6 @@ package net.averak.gsync.testkit import org.apache.commons.lang3.RandomStringUtils import org.jeasy.random.EasyRandom import org.jeasy.random.EasyRandomParameters -import java.time.LocalDate import java.util.* class Faker { @@ -69,7 +68,9 @@ class Faker { */ @JvmStatic fun email(): String { - return "${RandomStringUtils.randomAlphanumeric(10)}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) + return "${RandomStringUtils.randomAlphanumeric( + 10, + )}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) } /** @@ -157,29 +158,5 @@ class Faker { fun uuidv5(name: String): String { return UUID.nameUUIDFromBytes(name.toByteArray()).toString() } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun today(): LocalDate { - return LocalDate.now() - } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun tomorrow(): LocalDate { - return LocalDate.now().plusDays(1) - } - - /** - * 本日の日付を生成する - */ - @JvmStatic - fun yesterday(): LocalDate { - return LocalDate.now().minusDays(1) - } } } diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt new file mode 100644 index 0000000..0bae3ef --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt @@ -0,0 +1,40 @@ +package net.averak.gsync.testkit + +import groovy.sql.Sql +import jakarta.annotation.PostConstruct +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties +import org.springframework.boot.jdbc.DataSourceBuilder +import org.springframework.boot.test.context.TestConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.jdbc.datasource.TransactionAwareDataSourceProxy +import java.util.* +import javax.sql.DataSource + +@TestConfiguration +@EnableAutoConfiguration +internal open class TestConfig( + private val randomizers: List>, +) { + + @PostConstruct + open fun init() { + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + Faker.init(randomizers) + } + + @Bean + open fun dataSource(properties: DataSourceProperties): DataSource { + return TransactionAwareDataSourceProxy( + DataSourceBuilder.create() + .driverClassName(properties.driverClassName) + .url(properties.url) + .build(), + ) + } + + @Bean + open fun sql(dataSource: DataSource): Sql { + return Sql(dataSource) + } +} From e3ab50d328272e6b82e2fb5c8f9baa2e7926a695 Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 00:47:03 +0900 Subject: [PATCH 19/26] =?UTF-8?q?refs=20#8=20JPA=E3=81=ABSpanner=E3=81=AE?= =?UTF-8?q?=E6=8E=A5=E7=B6=9A=E6=83=85=E5=A0=B1=E3=82=92=E4=B8=8E=E3=81=88?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 5 +++++ gradle/libs.versions.toml | 1 + src/main/resources/application.yaml | 9 +++------ 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c8e769d..923f318 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -128,6 +128,10 @@ project(":adapter") { implementation(project(":usecase")) implementation(rootProject.libs.spring.boot.starter.web) implementation(rootProject.libs.spring.boot.starter.webflux) + implementation(rootProject.libs.spring.boot.starter.data.jpa) + implementation(rootProject.libs.google.cloud.spanner.spring) + implementation(rootProject.libs.google.cloud.spanner.jdbc) + implementation(rootProject.libs.google.cloud.spanner.hibernate) testImplementation(rootProject.libs.spring.boot.starter.test) } @@ -197,6 +201,7 @@ dependencies { implementation(project(":infrastructure")) implementation(project(":usecase")) implementation(libs.spring.boot.starter) + implementation(libs.spring.boot.starter.data.jpa) implementation(libs.google.cloud.spanner.spring) implementation(libs.google.cloud.spanner.jdbc) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 730bc5c..187e249 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-start spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot" } google-cloud-spanner-spring = "com.google.cloud:spring-cloud-gcp-starter-data-spanner:5.0.0" google-cloud-spanner-jdbc = "com.google.cloud:google-cloud-spanner-jdbc:2.15.0" +google-cloud-spanner-hibernate = "com.google.cloud:google-cloud-spanner-hibernate-dialect:3.0.3" [plugins] gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 039f1a8..e368318 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -10,16 +10,13 @@ spring: database: ${GCP_SPANNER_DATABASE:sandbox} emulator-host: ${SPANNER_EMULATOR_HOST:localhost:9010} datasource: - url: jdbc:cloudspanner:/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database} + url: jdbc:cloudspanner://${spring.cloud.gcp.spanner.emulator-host}/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database} driver-class-name: com.google.cloud.spanner.jdbc.JdbcDriver jpa: hibernate: ddl-auto: update - properties: - hibernate: - dialect: com.google.cloud.spanner.hibernate.SpannerDialect - show_sql: true - format_sql: true + database-platform: com.google.cloud.spanner.hibernate.SpannerDialect + show-sql: true data: redis: host: ${REDIS_HOST:localhost} From d67c25ae1a712599715cb55c645d98dc251f897a Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 01:10:51 +0900 Subject: [PATCH 20/26] =?UTF-8?q?refs=20#8=20Spanner=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88=E3=82=92=E5=88=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../net/averak/gsync/adapter/dao/EchoDao.kt | 13 ++++++++ .../gsync/adapter/dao/entity/EchoEntity.kt | 22 ++++++++++++++ .../rest/GlobalRestControllerAdvice.kt | 16 ++++------ .../adapter/repository/EchoRepository.kt | 16 ++++++++++ .../adapter/transaction/SpannerTransaction.kt | 30 +++++++++++++++++++ .../domain/repository/IEchoRepository.kt | 8 +++++ .../net/averak/gsync/usecase/EchoUsecase.kt | 13 ++++++-- .../gsync/usecase/transaction/ITransaction.kt | 8 +++++ build.gradle.kts | 5 ++-- .../kotlin/net/averak/gsync/Entrypoint.kt | 5 +++- src/main/resources/application.yaml | 13 ++++++-- src/test/resources/logback-test.xml | 2 +- 12 files changed, 132 insertions(+), 19 deletions(-) create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt create mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/transaction/SpannerTransaction.kt create mode 100644 app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt create mode 100644 app/usecase/src/main/kotlin/net/averak/gsync/usecase/transaction/ITransaction.kt diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt new file mode 100644 index 0000000..55db520 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt @@ -0,0 +1,13 @@ +package net.averak.gsync.adapter.dao + +import com.google.cloud.spring.data.spanner.repository.SpannerRepository +import com.google.cloud.spring.data.spanner.repository.query.Query +import net.averak.gsync.adapter.dao.entity.EchoEntity +import java.time.LocalDateTime +import java.util.* + +interface EchoDao : SpannerRepository { + + @Query("INSERT INTO echo (id, message, timestamp) VALUES (@id, @message, @timestamp)", dmlStatement = true) + fun insert(id: UUID, message: String, timestamp: LocalDateTime) +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt new file mode 100644 index 0000000..263d623 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt @@ -0,0 +1,22 @@ +package net.averak.gsync.adapter.dao.entity + +import jakarta.persistence.Column +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import java.sql.Types +import java.time.LocalDateTime +import java.util.* + +@Table(name = "echo") +data class EchoEntity( + @Id + @JdbcTypeCode(Types.CHAR) + var id: UUID, + + @Column(length = 255) + var message: String, + + @Column + var timestamp: LocalDateTime, +) diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt index f276df3..f37d715 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt @@ -48,24 +48,20 @@ class GlobalRestControllerAdvice( GsyncException(ex) } + val body = mapOf( + "code" to e.errorCode.name, + "message" to e.errorCode.summary, + ) when (e.errorCode) { ErrorCode.NOT_FOUND_API -> { this.customLogger.warn(requestScope.getGameContext(), e) - return ResponseEntity(ErrorResponse(e), HttpStatus.NOT_FOUND) + return ResponseEntity(body, HttpStatus.NOT_FOUND) } else -> { this.customLogger.error(requestScope.getGameContext(), e) - return ResponseEntity(ErrorResponse(e), HttpStatus.INTERNAL_SERVER_ERROR) + return ResponseEntity(body, HttpStatus.INTERNAL_SERVER_ERROR) } } } - - data class ErrorResponse( - val code: String, - val message: String, - ) { - - constructor(ex: GsyncException) : this(ex.errorCode.name, ex.errorCode.summary) - } } diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt new file mode 100644 index 0000000..67caaca --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt @@ -0,0 +1,16 @@ +package net.averak.gsync.adapter.repository + +import net.averak.gsync.adapter.dao.EchoDao +import net.averak.gsync.domain.model.Echo +import net.averak.gsync.domain.repository.IEchoRepository +import org.springframework.stereotype.Repository + +@Repository +open class EchoRepository( + private val echoDao: EchoDao, +) : IEchoRepository { + + override fun save(echo: Echo) { + echoDao.insert(echo.id, echo.message, echo.timestamp) + } +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/transaction/SpannerTransaction.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/transaction/SpannerTransaction.kt new file mode 100644 index 0000000..1484576 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/transaction/SpannerTransaction.kt @@ -0,0 +1,30 @@ +package net.averak.gsync.adapter.transaction + +import net.averak.gsync.usecase.transaction.ITransaction +import org.springframework.stereotype.Component +import org.springframework.transaction.PlatformTransactionManager +import org.springframework.transaction.support.TransactionTemplate + +@Component +class SpannerTransaction( + private val transactionManager: PlatformTransactionManager, +) : ITransaction { + + override fun beginRoTransaction(block: () -> T): T { + val tx = TransactionTemplate(transactionManager) + tx.isReadOnly = true + + return tx.execute { + block() + }!! + } + + override fun beginRwTransaction(block: () -> T): T { + val tx = TransactionTemplate(transactionManager) + tx.isReadOnly = false + + return tx.execute { + block() + }!! + } +} diff --git a/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt b/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt new file mode 100644 index 0000000..106dc15 --- /dev/null +++ b/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt @@ -0,0 +1,8 @@ +package net.averak.gsync.domain.repository + +import net.averak.gsync.domain.model.Echo + +fun interface IEchoRepository { + + fun save(echo: Echo) +} diff --git a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt index 2cbbc4c..68c015e 100644 --- a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -2,12 +2,21 @@ package net.averak.gsync.usecase import net.averak.gsync.core.gamecontext.GameContext import net.averak.gsync.domain.model.Echo +import net.averak.gsync.domain.repository.IEchoRepository +import net.averak.gsync.usecase.transaction.ITransaction import org.springframework.stereotype.Service @Service -class EchoUsecase { +class EchoUsecase( + private val transaction: ITransaction, + private val echoRepository: IEchoRepository, +) { fun echo(gctx: GameContext, message: String): Echo { - return Echo(message, gctx.currentTime) + return transaction.beginRwTransaction { + val echo = Echo(message, gctx.currentTime) + echoRepository.save(echo) + return@beginRwTransaction echo + } } } diff --git a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/transaction/ITransaction.kt b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/transaction/ITransaction.kt new file mode 100644 index 0000000..5930fd7 --- /dev/null +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/transaction/ITransaction.kt @@ -0,0 +1,8 @@ +package net.averak.gsync.usecase.transaction + +interface ITransaction { + + fun beginRoTransaction(block: () -> T): T + + fun beginRwTransaction(block: () -> T): T +} diff --git a/build.gradle.kts b/build.gradle.kts index 923f318..8e4216d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -130,7 +130,6 @@ project(":adapter") { implementation(rootProject.libs.spring.boot.starter.webflux) implementation(rootProject.libs.spring.boot.starter.data.jpa) implementation(rootProject.libs.google.cloud.spanner.spring) - implementation(rootProject.libs.google.cloud.spanner.jdbc) implementation(rootProject.libs.google.cloud.spanner.hibernate) testImplementation(rootProject.libs.spring.boot.starter.test) @@ -155,6 +154,7 @@ project(":infrastructure") { dependencies { implementation(rootProject.libs.spring.boot.starter.web) implementation(rootProject.libs.spring.boot.starter.webflux) + implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.jackson.module.kotlin) implementation(rootProject.libs.jackson.datatype.jsr310) } @@ -164,6 +164,7 @@ project(":usecase") { dependencies { implementation(project(":core")) implementation(project(":domain")) + implementation(rootProject.libs.google.cloud.spanner.spring) } } @@ -176,7 +177,6 @@ project(":testkit") { implementation(project(":infrastructure")) implementation(project(":usecase")) implementation(rootProject.libs.spring.boot.starter.test) - implementation(rootProject.libs.spring.boot.starter.data.jpa) implementation(rootProject.libs.spring.boot.starter.data.redis) implementation(rootProject.libs.commons.lang3) @@ -202,7 +202,6 @@ dependencies { implementation(project(":usecase")) implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.data.jpa) - implementation(libs.google.cloud.spanner.spring) implementation(libs.google.cloud.spanner.jdbc) } diff --git a/src/main/kotlin/net/averak/gsync/Entrypoint.kt b/src/main/kotlin/net/averak/gsync/Entrypoint.kt index b9eb9fb..3c6e748 100644 --- a/src/main/kotlin/net/averak/gsync/Entrypoint.kt +++ b/src/main/kotlin/net/averak/gsync/Entrypoint.kt @@ -8,7 +8,10 @@ import org.springframework.context.annotation.ComponentScan import java.util.* @SpringBootApplication -@ComponentScan(basePackages = ["net.averak.gsync"], nameGenerator = Entrypoint.FQCNBeanNameGenerator::class) +@ComponentScan( + basePackages = ["net.averak.gsync"], + nameGenerator = Entrypoint.FQCNBeanNameGenerator::class, +) open class Entrypoint { class FQCNBeanNameGenerator : AnnotationBeanNameGenerator() { diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e368318..529cccd 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -8,15 +8,21 @@ spring: project-id: ${GCP_PROJECT_ID:gsync-sandbox} instance-id: ${GCP_SPANNER_INSTANCE_ID:sandbox} database: ${GCP_SPANNER_DATABASE:sandbox} + emulator: + enabled: true emulator-host: ${SPANNER_EMULATOR_HOST:localhost:9010} datasource: - url: jdbc:cloudspanner://${spring.cloud.gcp.spanner.emulator-host}/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database} + url: jdbc:cloudspanner://${spring.cloud.gcp.spanner.emulator-host}/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database}?autoConfigEmulator=${spring.cloud.gcp.spanner.emulator.enabled} driver-class-name: com.google.cloud.spanner.jdbc.JdbcDriver jpa: hibernate: ddl-auto: update database-platform: com.google.cloud.spanner.hibernate.SpannerDialect - show-sql: true + properties: + hibernate: + jdbc: + batch_size: 100 + order_updates: true data: redis: host: ${REDIS_HOST:localhost} @@ -25,6 +31,9 @@ spring: jackson: date-format: yyyy-MM-dd'T'HH:mm:ss'Z' time-zone: UTC + main: + allow-bean-definition-overriding: true + server: port: ${PORT:8080} servlet: diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index e4d6095..d67b59c 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -6,7 +6,7 @@ - + From 957cd28179fa040520d1776471826f65fef0b3c4 Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 06:14:31 +0900 Subject: [PATCH 21/26] =?UTF-8?q?refs=20#8=20flyway=E3=82=92=E5=B0=8E?= =?UTF-8?q?=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 4 ++++ gradle/libs.versions.toml | 5 +++++ src/main/resources/application.yaml | 9 +++++++++ src/main/resources/migration/V1_0_0_0__init_schema.sql | 10 ++++++++++ 4 files changed, 28 insertions(+) create mode 100644 src/main/resources/migration/V1_0_0_0__init_schema.sql diff --git a/build.gradle.kts b/build.gradle.kts index 8e4216d..17d2ee9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) + alias(libs.plugins.flyway) alias(libs.plugins.gradle.git.properties) alias(libs.plugins.spotless) alias(libs.plugins.sonarqube) @@ -16,6 +17,7 @@ plugins { buildscript { dependencies { classpath(libs.spring.boot.gradle.plugin) + classpath(libs.flyway.gradle.plugin) } } @@ -203,6 +205,8 @@ dependencies { implementation(libs.spring.boot.starter) implementation(libs.spring.boot.starter.data.jpa) implementation(libs.google.cloud.spanner.jdbc) + implementation(libs.flyway.core) + implementation(libs.flyway.spanner) } gitProperties { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 187e249..b9aa54f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] easy-random = "6.2.1" +flyway = "10.4.1" groovy = "4.0.7" kotlin = "1.9.20" spock = "2.4-M1-groovy-4.0" @@ -8,6 +9,9 @@ spring-boot = "3.2.1" [libraries] commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } +flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } +flyway-spanner = { module = "org.flywaydb:flyway-gcp-spanner", version.ref = "flyway" } +flyway-gradle-plugin = { module = "org.flywaydb:flyway-gradle-plugin", version.ref = "flyway" } groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } guava = "com.google.guava:guava:32.1.3-jre" @@ -29,6 +33,7 @@ google-cloud-spanner-jdbc = "com.google.cloud:google-cloud-spanner-jdbc:2.15.0" google-cloud-spanner-hibernate = "com.google.cloud:google-cloud-spanner-hibernate-dialect:3.0.3" [plugins] +flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" } gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" sonarqube = "org.sonarqube:4.4.1.3373" spotless = "com.diffplug.spotless:6.22.0" diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 529cccd..b484364 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -33,6 +33,15 @@ spring: time-zone: UTC main: allow-bean-definition-overriding: true + flyway: + url: ${spring.datasource.url} + driver-class-name: ${spring.datasource.driver-class-name} + enabled: true + baseline-on-migrate: true + validate-on-migrate: false + outOfOrder: false + locations: classpath:/migration + connect-retries: 5 server: port: ${PORT:8080} diff --git a/src/main/resources/migration/V1_0_0_0__init_schema.sql b/src/main/resources/migration/V1_0_0_0__init_schema.sql new file mode 100644 index 0000000..8afa037 --- /dev/null +++ b/src/main/resources/migration/V1_0_0_0__init_schema.sql @@ -0,0 +1,10 @@ +START BATCH DDL; + +CREATE TABLE echo +( + id STRING(36) NOT NULL, + message STRING(255) NOT NULL, + timestamp TIMESTAMP NOT NULL, +) PRIMARY KEY (id); + +RUN BATCH; From 7d92729ca2cd21870725b407ea2255c5efbbf410 Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 06:30:33 +0900 Subject: [PATCH 22/26] =?UTF-8?q?refs=20#8=20MyBatis=20Generator=E3=81=A7?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 15 +- .../adapter/dao/entity/base/EchoEntity.java | 134 +++++ .../adapter/dao/entity/base/EchoExample.java | 502 ++++++++++++++++++ .../dao/mapper/base/EchoBaseMapper.java | 107 ++++ .../adapter/dao/mapper/extend/EchoMapper.java | 9 + .../net/averak/gsync/adapter/dao/EchoDao.kt | 13 - .../gsync/adapter/dao/entity/EchoEntity.kt | 22 - .../rest/GlobalRestControllerAdvice.kt | 13 +- .../adapter/handler/rest/HttpRequestScope.kt | 2 +- .../interceptor/GameContextInterceptor.kt | 2 +- .../adapter/repository/EchoRepository.kt | 14 +- .../handler/rest/AbstractController_IT.groovy | 3 +- .../rest/GlobalRestControllerAdvice_IT.groovy | 3 +- .../rest/HealthCheckController_IT.groovy | 12 +- .../averak/gsync/core/daterange/DateRange.kt | 6 +- .../averak/gsync/core/daterange/Dateline.kt | 5 +- .../GameContext.kt | 2 +- .../net/averak/gsync/core/logger/Logger.kt | 2 +- .../gsync/core/daterange/DateRange_UT.groovy | 71 +++ .../gsync/core/daterange/Dateline_UT.groovy | 76 +++ .../core/game_context/GameContext_UT.groovy | 33 ++ .../mybatis/IgnoreTablePlugin.kt | 45 ++ .../mybatis/RenameGeneratedFilesPlugin.kt | 31 ++ .../mybatis/ResolveNullPlugin.kt | 57 ++ .../net/averak/gsync/usecase/EchoUsecase.kt | 2 +- .../gsync/usecase/AbstractUsecase_UT.groovy | 5 + .../gsync/usecase/EchoUsecase_UT.groovy | 3 +- build.gradle.kts | 45 +- gradle/libs.versions.toml | 4 + src/main/resources/application.yaml | 9 +- .../resources/dao/base/EchoBaseMapper.xml | 261 +++++++++ src/main/resources/dao/extend/EchoMapper.xml | 4 + .../db/migration/V1_0_0_0__init_schema.sql | 6 + .../migration/V1_0_0_0__init_schema.sql | 10 - .../resources/mybatis-generator-config.xml | 40 ++ src/test/resources/application-test.yaml | 2 + src/test/resources/logback-test.xml | 2 +- .../net/averak/gsync/testkit/Assert.groovy | 8 +- .../net/averak/gsync/testkit/TestConfig.kt | 20 + 39 files changed, 1528 insertions(+), 72 deletions(-) create mode 100644 app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java create mode 100644 app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java create mode 100644 app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/base/EchoBaseMapper.java create mode 100644 app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/extend/EchoMapper.java delete mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt delete mode 100644 app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt rename app/core/src/main/kotlin/net/averak/gsync/core/{gamecontext => game_context}/GameContext.kt (91%) create mode 100644 app/core/src/test/groovy/net/averak/gsync/core/daterange/DateRange_UT.groovy create mode 100644 app/core/src/test/groovy/net/averak/gsync/core/daterange/Dateline_UT.groovy create mode 100644 app/core/src/test/groovy/net/averak/gsync/core/game_context/GameContext_UT.groovy create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt create mode 100644 src/main/resources/dao/base/EchoBaseMapper.xml create mode 100644 src/main/resources/dao/extend/EchoMapper.xml create mode 100644 src/main/resources/db/migration/V1_0_0_0__init_schema.sql delete mode 100644 src/main/resources/migration/V1_0_0_0__init_schema.sql create mode 100644 src/main/resources/mybatis-generator-config.xml diff --git a/Makefile b/Makefile index da08a3c..74a356e 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: build build: - ./gradlew bootJar + ./gradlew build -x test .PHONY: test test: @@ -14,6 +14,19 @@ lint: format: ./gradlew spotlessApply +.PHONY: codegen +codegen: + ./gradlew mbGenerate + ./gradlew spotlessApply + +.PHONY: db-migrate +db-migrate: + ./gradlew flywayMigrate + +.PHONY: db-clean +db-clean: + ./gradlew flywayClean + .PHONY: check_dependencies check_dependencies: ./gradlew dependencyUpdates -Drevision=release diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java new file mode 100644 index 0000000..cd5acc2 --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java @@ -0,0 +1,134 @@ +package net.averak.gsync.adapter.dao.entity.base; + +import java.util.Date; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; + +public class EchoEntity { + /** + * + * This field was generated by MyBatis Generator. This field corresponds to the + * database column gsync_echo.echo_id + * + * @mbg.generated + */ + private String echoId; + + /** + * + * This field was generated by MyBatis Generator. This field corresponds to the + * database column gsync_echo.message + * + * @mbg.generated + */ + private String message; + + /** + * + * This field was generated by MyBatis Generator. This field corresponds to the + * database column gsync_echo.timestamp + * + * @mbg.generated + */ + private Date timestamp; + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public EchoEntity(@Nonnull String echoId, @Nonnull String message, @Nonnull Date timestamp) { + this.echoId = echoId; + this.message = message; + this.timestamp = timestamp; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public EchoEntity() { + super(); + } + + /** + * This method was generated by MyBatis Generator. This method returns the value + * of the database column gsync_echo.echo_id + * + * @return the value of gsync_echo.echo_id + * + * @mbg.generated + */ + @Nonnull + public String getEchoId() { + return echoId; + } + + /** + * This method was generated by MyBatis Generator. This method sets the value of + * the database column gsync_echo.echo_id + * + * @param echoId + * the value for gsync_echo.echo_id + * + * @mbg.generated + */ + public void setEchoId(@Nonnull String echoId) { + this.echoId = echoId; + } + + /** + * This method was generated by MyBatis Generator. This method returns the value + * of the database column gsync_echo.message + * + * @return the value of gsync_echo.message + * + * @mbg.generated + */ + @Nonnull + public String getMessage() { + return message; + } + + /** + * This method was generated by MyBatis Generator. This method sets the value of + * the database column gsync_echo.message + * + * @param message + * the value for gsync_echo.message + * + * @mbg.generated + */ + public void setMessage(@Nonnull String message) { + this.message = message; + } + + /** + * This method was generated by MyBatis Generator. This method returns the value + * of the database column gsync_echo.timestamp + * + * @return the value of gsync_echo.timestamp + * + * @mbg.generated + */ + @Nonnull + public Date getTimestamp() { + return timestamp; + } + + /** + * This method was generated by MyBatis Generator. This method sets the value of + * the database column gsync_echo.timestamp + * + * @param timestamp + * the value for gsync_echo.timestamp + * + * @mbg.generated + */ + public void setTimestamp(@Nonnull Date timestamp) { + this.timestamp = timestamp; + } +} \ No newline at end of file diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java new file mode 100644 index 0000000..fe85cfe --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java @@ -0,0 +1,502 @@ +package net.averak.gsync.adapter.dao.entity.base; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +public class EchoExample { + /** + * This field was generated by MyBatis Generator. This field corresponds to the + * database table gsync_echo + * + * @mbg.generated + */ + protected String orderByClause; + + /** + * This field was generated by MyBatis Generator. This field corresponds to the + * database table gsync_echo + * + * @mbg.generated + */ + protected boolean distinct; + + /** + * This field was generated by MyBatis Generator. This field corresponds to the + * database table gsync_echo + * + * @mbg.generated + */ + protected List oredCriteria; + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public EchoExample() { + oredCriteria = new ArrayList<>(); + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public void setOrderByClause(String orderByClause) { + this.orderByClause = orderByClause; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public String getOrderByClause() { + return orderByClause; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public void setDistinct(boolean distinct) { + this.distinct = distinct; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public boolean isDistinct() { + return distinct; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public List getOredCriteria() { + return oredCriteria; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public void or(Criteria criteria) { + oredCriteria.add(criteria); + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public Criteria or() { + Criteria criteria = createCriteriaInternal(); + oredCriteria.add(criteria); + return criteria; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public Criteria createCriteria() { + Criteria criteria = createCriteriaInternal(); + if (oredCriteria.size() == 0) { + oredCriteria.add(criteria); + } + return criteria; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + protected Criteria createCriteriaInternal() { + Criteria criteria = new Criteria(); + return criteria; + } + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + public void clear() { + oredCriteria.clear(); + orderByClause = null; + distinct = false; + } + + /** + * This class was generated by MyBatis Generator. This class corresponds to the + * database table gsync_echo + * + * @mbg.generated + */ + protected abstract static class GeneratedCriteria { + protected List criteria; + + protected GeneratedCriteria() { + super(); + criteria = new ArrayList<>(); + } + + public boolean isValid() { + return criteria.size() > 0; + } + + public List getAllCriteria() { + return criteria; + } + + public List getCriteria() { + return criteria; + } + + protected void addCriterion(String condition) { + if (condition == null) { + throw new RuntimeException("Value for condition cannot be null"); + } + criteria.add(new Criterion(condition)); + } + + protected void addCriterion(String condition, Object value, String property) { + if (value == null) { + throw new RuntimeException("Value for " + property + " cannot be null"); + } + criteria.add(new Criterion(condition, value)); + } + + protected void addCriterion(String condition, Object value1, Object value2, String property) { + if (value1 == null || value2 == null) { + throw new RuntimeException("Between values for " + property + " cannot be null"); + } + criteria.add(new Criterion(condition, value1, value2)); + } + + public Criteria andEchoIdIsNull() { + addCriterion("`echo_id` is null"); + return (Criteria) this; + } + + public Criteria andEchoIdIsNotNull() { + addCriterion("`echo_id` is not null"); + return (Criteria) this; + } + + public Criteria andEchoIdEqualTo(String value) { + addCriterion("`echo_id` =", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdNotEqualTo(String value) { + addCriterion("`echo_id` <>", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdGreaterThan(String value) { + addCriterion("`echo_id` >", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdGreaterThanOrEqualTo(String value) { + addCriterion("`echo_id` >=", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdLessThan(String value) { + addCriterion("`echo_id` <", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdLessThanOrEqualTo(String value) { + addCriterion("`echo_id` <=", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdLike(String value) { + addCriterion("`echo_id` like", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdNotLike(String value) { + addCriterion("`echo_id` not like", value, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdIn(List values) { + addCriterion("`echo_id` in", values, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdNotIn(List values) { + addCriterion("`echo_id` not in", values, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdBetween(String value1, String value2) { + addCriterion("`echo_id` between", value1, value2, "echoId"); + return (Criteria) this; + } + + public Criteria andEchoIdNotBetween(String value1, String value2) { + addCriterion("`echo_id` not between", value1, value2, "echoId"); + return (Criteria) this; + } + + public Criteria andMessageIsNull() { + addCriterion("`message` is null"); + return (Criteria) this; + } + + public Criteria andMessageIsNotNull() { + addCriterion("`message` is not null"); + return (Criteria) this; + } + + public Criteria andMessageEqualTo(String value) { + addCriterion("`message` =", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageNotEqualTo(String value) { + addCriterion("`message` <>", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageGreaterThan(String value) { + addCriterion("`message` >", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageGreaterThanOrEqualTo(String value) { + addCriterion("`message` >=", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageLessThan(String value) { + addCriterion("`message` <", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageLessThanOrEqualTo(String value) { + addCriterion("`message` <=", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageLike(String value) { + addCriterion("`message` like", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageNotLike(String value) { + addCriterion("`message` not like", value, "message"); + return (Criteria) this; + } + + public Criteria andMessageIn(List values) { + addCriterion("`message` in", values, "message"); + return (Criteria) this; + } + + public Criteria andMessageNotIn(List values) { + addCriterion("`message` not in", values, "message"); + return (Criteria) this; + } + + public Criteria andMessageBetween(String value1, String value2) { + addCriterion("`message` between", value1, value2, "message"); + return (Criteria) this; + } + + public Criteria andMessageNotBetween(String value1, String value2) { + addCriterion("`message` not between", value1, value2, "message"); + return (Criteria) this; + } + + public Criteria andTimestampIsNull() { + addCriterion("`timestamp` is null"); + return (Criteria) this; + } + + public Criteria andTimestampIsNotNull() { + addCriterion("`timestamp` is not null"); + return (Criteria) this; + } + + public Criteria andTimestampEqualTo(Date value) { + addCriterion("`timestamp` =", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampNotEqualTo(Date value) { + addCriterion("`timestamp` <>", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampGreaterThan(Date value) { + addCriterion("`timestamp` >", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampGreaterThanOrEqualTo(Date value) { + addCriterion("`timestamp` >=", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampLessThan(Date value) { + addCriterion("`timestamp` <", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampLessThanOrEqualTo(Date value) { + addCriterion("`timestamp` <=", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampIn(List values) { + addCriterion("`timestamp` in", values, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampNotIn(List values) { + addCriterion("`timestamp` not in", values, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampBetween(Date value1, Date value2) { + addCriterion("`timestamp` between", value1, value2, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampNotBetween(Date value1, Date value2) { + addCriterion("`timestamp` not between", value1, value2, "timestamp"); + return (Criteria) this; + } + } + + /** + * This class was generated by MyBatis Generator. This class corresponds to the + * database table gsync_echo + * + * @mbg.generated do_not_delete_during_merge + */ + public static class Criteria extends GeneratedCriteria { + protected Criteria() { + super(); + } + } + + /** + * This class was generated by MyBatis Generator. This class corresponds to the + * database table gsync_echo + * + * @mbg.generated + */ + public static class Criterion { + private String condition; + + private Object value; + + private Object secondValue; + + private boolean noValue; + + private boolean singleValue; + + private boolean betweenValue; + + private boolean listValue; + + private String typeHandler; + + public String getCondition() { + return condition; + } + + public Object getValue() { + return value; + } + + public Object getSecondValue() { + return secondValue; + } + + public boolean isNoValue() { + return noValue; + } + + public boolean isSingleValue() { + return singleValue; + } + + public boolean isBetweenValue() { + return betweenValue; + } + + public boolean isListValue() { + return listValue; + } + + public String getTypeHandler() { + return typeHandler; + } + + protected Criterion(String condition) { + super(); + this.condition = condition; + this.typeHandler = null; + this.noValue = true; + } + + protected Criterion(String condition, Object value, String typeHandler) { + super(); + this.condition = condition; + this.value = value; + this.typeHandler = typeHandler; + if (value instanceof List) { + this.listValue = true; + } else { + this.singleValue = true; + } + } + + protected Criterion(String condition, Object value) { + this(condition, value, null); + } + + protected Criterion(String condition, Object value, Object secondValue, String typeHandler) { + super(); + this.condition = condition; + this.value = value; + this.secondValue = secondValue; + this.typeHandler = typeHandler; + this.betweenValue = true; + } + + protected Criterion(String condition, Object value, Object secondValue) { + this(condition, value, secondValue, null); + } + } +} \ No newline at end of file diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/base/EchoBaseMapper.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/base/EchoBaseMapper.java new file mode 100644 index 0000000..b0537a6 --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/base/EchoBaseMapper.java @@ -0,0 +1,107 @@ +package net.averak.gsync.adapter.dao.mapper.base; + +import java.util.List; +import net.averak.gsync.adapter.dao.entity.base.EchoEntity; +import net.averak.gsync.adapter.dao.entity.base.EchoExample; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.session.RowBounds; + +@Mapper +public interface EchoBaseMapper { + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + long countByExample(EchoExample example); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int deleteByExample(EchoExample example); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int deleteByPrimaryKey(String echoId); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int insert(EchoEntity row); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int insertSelective(EchoEntity row); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + List selectByExampleWithRowbounds(EchoExample example, RowBounds rowBounds); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + List selectByExample(EchoExample example); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + EchoEntity selectByPrimaryKey(String echoId); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int updateByExampleSelective(@Param("row") EchoEntity row, @Param("example") EchoExample example); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int updateByExample(@Param("row") EchoEntity row, @Param("example") EchoExample example); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int updateByPrimaryKeySelective(EchoEntity row); + + /** + * This method was generated by MyBatis Generator. This method corresponds to + * the database table gsync_echo + * + * @mbg.generated + */ + int updateByPrimaryKey(EchoEntity row); +} \ No newline at end of file diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/extend/EchoMapper.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/extend/EchoMapper.java new file mode 100644 index 0000000..2854bdd --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/mapper/extend/EchoMapper.java @@ -0,0 +1,9 @@ +package net.averak.gsync.adapter.dao.mapper.extend; + +import net.averak.gsync.adapter.dao.mapper.base.EchoBaseMapper; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface EchoMapper extends EchoBaseMapper { + +} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt deleted file mode 100644 index 55db520..0000000 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/EchoDao.kt +++ /dev/null @@ -1,13 +0,0 @@ -package net.averak.gsync.adapter.dao - -import com.google.cloud.spring.data.spanner.repository.SpannerRepository -import com.google.cloud.spring.data.spanner.repository.query.Query -import net.averak.gsync.adapter.dao.entity.EchoEntity -import java.time.LocalDateTime -import java.util.* - -interface EchoDao : SpannerRepository { - - @Query("INSERT INTO echo (id, message, timestamp) VALUES (@id, @message, @timestamp)", dmlStatement = true) - fun insert(id: UUID, message: String, timestamp: LocalDateTime) -} diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt deleted file mode 100644 index 263d623..0000000 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/dao/entity/EchoEntity.kt +++ /dev/null @@ -1,22 +0,0 @@ -package net.averak.gsync.adapter.dao.entity - -import jakarta.persistence.Column -import jakarta.persistence.Id -import jakarta.persistence.Table -import org.hibernate.annotations.JdbcTypeCode -import java.sql.Types -import java.time.LocalDateTime -import java.util.* - -@Table(name = "echo") -data class EchoEntity( - @Id - @JdbcTypeCode(Types.CHAR) - var id: UUID, - - @Column(length = 255) - var message: String, - - @Column - var timestamp: LocalDateTime, -) diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt index f37d715..75dbe79 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt @@ -48,10 +48,7 @@ class GlobalRestControllerAdvice( GsyncException(ex) } - val body = mapOf( - "code" to e.errorCode.name, - "message" to e.errorCode.summary, - ) + val body = ErrorResponse(e) when (e.errorCode) { ErrorCode.NOT_FOUND_API -> { this.customLogger.warn(requestScope.getGameContext(), e) @@ -64,4 +61,12 @@ class GlobalRestControllerAdvice( } } } + + data class ErrorResponse( + val code: String, + val message: String, + ) { + + constructor(ex: GsyncException) : this(ex.errorCode.name, ex.errorCode.summary) + } } diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt index 930770e..eb282e0 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt @@ -2,7 +2,7 @@ package net.averak.gsync.adapter.handler.rest import jakarta.servlet.http.HttpServletRequest import net.averak.gsync.core.config.Config -import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.core.game_context.GameContext import org.springframework.stereotype.Component import java.time.LocalDateTime import java.time.format.DateTimeFormatter diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt index 92a8358..22b8d14 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt @@ -5,7 +5,7 @@ import jakarta.servlet.http.HttpServletResponse import net.averak.gsync.adapter.handler.rest.HttpRequestScope import net.averak.gsync.core.config.Config import net.averak.gsync.core.daterange.Dateline -import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.core.game_context.GameContext import org.springframework.stereotype.Component import org.springframework.web.servlet.ModelAndView import java.time.LocalDateTime diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt index 67caaca..150b805 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt @@ -1,16 +1,24 @@ package net.averak.gsync.adapter.repository -import net.averak.gsync.adapter.dao.EchoDao +import net.averak.gsync.adapter.dao.entity.base.EchoEntity +import net.averak.gsync.adapter.dao.mapper.extend.EchoMapper import net.averak.gsync.domain.model.Echo import net.averak.gsync.domain.repository.IEchoRepository import org.springframework.stereotype.Repository +import java.time.ZoneOffset +import java.util.* @Repository open class EchoRepository( - private val echoDao: EchoDao, + private val echoMapper: EchoMapper, ) : IEchoRepository { override fun save(echo: Echo) { - echoDao.insert(echo.id, echo.message, echo.timestamp) + val entity = EchoEntity( + echo.id.toString(), + echo.message, + Date.from(echo.timestamp.toInstant(ZoneOffset.UTC)), + ) + echoMapper.insert(entity) } } diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy index 48efb00..561af70 100644 --- a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy @@ -135,10 +135,11 @@ abstract class AbstractController_IT extends AbstractDatabaseSpec { * * @return error response */ - GlobalRestControllerAdvice.ErrorResponse execute(final MockHttpServletRequestBuilder request, final GsyncException ex) { + GlobalRestControllerAdvice.ErrorResponse execute(final MockHttpServletRequestBuilder request, final HttpStatus expectedHttpStatus, final GsyncException ex) { final result = mockMvc.perform(request).andReturn() final response = JsonUtils.decode(result.response.contentAsString, GlobalRestControllerAdvice.ErrorResponse.class) + assert result.response.status == expectedHttpStatus.value() assert response.code == ex.errorCode.name() assert response.message == ex.errorCode.summary return response diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy index 74d18f6..59c6177 100644 --- a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy @@ -2,6 +2,7 @@ package net.averak.gsync.adapter.handler.rest import net.averak.gsync.core.exception.ErrorCode import net.averak.gsync.core.exception.GsyncException +import org.springframework.http.HttpStatus class GlobalRestControllerAdvice_IT extends AbstractController_IT { @@ -11,6 +12,6 @@ class GlobalRestControllerAdvice_IT extends AbstractController_IT { expect: final request = this.getRequest(path) - execute(request, new GsyncException(ErrorCode.NOT_FOUND_API)) + execute(request, HttpStatus.NOT_FOUND, new GsyncException(ErrorCode.NOT_FOUND_API)) } } diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy index 52fcaf6..b33e064 100644 --- a/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy @@ -1,7 +1,10 @@ package net.averak.gsync.adapter.handler.rest +import net.averak.gsync.testkit.Assert import org.springframework.http.HttpStatus +import java.time.LocalDateTime + class HealthCheckController_IT extends AbstractController_IT { // API PATH @@ -9,8 +12,15 @@ class HealthCheckController_IT extends AbstractController_IT { static final String HEALTH_CHECK_PATH = BASE_PATH def "ヘルスチェックAPI: 正常系 200 OKを返す"() { - expect: + when: final request = this.getRequest(HEALTH_CHECK_PATH) this.execute(request, HttpStatus.OK) + + then: + with(sql.rows("SELECT * FROM gsync_echo")) { + it.size() == 1 + it[0].message == "Health Check" + Assert.timestampIs(it[0].timestamp, LocalDateTime.now()) + } } } diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt index 9d55f99..07691f7 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt @@ -4,7 +4,7 @@ import java.time.LocalDate import java.time.LocalDateTime /** - * サービス内の「o月x日」を表す時刻範囲オブジェクト + * ゲーム内の「o月x日」を表す時刻範囲オブジェクト * * [from, to) の半開区間のため to は含まない */ @@ -14,7 +14,9 @@ data class DateRange( val to: LocalDateTime, ) { - constructor(time: LocalDateTime) : this(Dateline.DEFAULT.getDateRangeAtTime(time)) + constructor(time: LocalDateTime) : this(time, Dateline.DEFAULT) + + constructor(time: LocalDateTime, dateline: Dateline) : this(dateline.getDateRangeAtTime(time)) private constructor(dateRange: DateRange) : this(dateRange.date, dateRange.from, dateRange.to) diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt index dd0d055..759d1b4 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt @@ -5,7 +5,7 @@ import java.time.LocalDateTime import java.time.LocalTime /** - * サービス内で日付が変更される境界線を表すオブジェクト (UTC) + * ゲーム内で日付が変更される境界線を表すオブジェクト (UTC) * -12:00:00 から +12:00:00 までの値をとる * * 例) Dateline が 00:00:00 の場合 @@ -29,10 +29,11 @@ data class Dateline( companion object { + // 06:00 (JST) に日付を切り替えるので、06:00:00 - 09:00:00 = -03:00:00 (UTC) になる // 22:00 (JST) に注文を締め切ることから、デフォルトの日付変更境界線は -11:00:00 (UTC) とする // JST だと -02:00:00 なので、UTC だと -02:00:00 - 09:00:00 = -11:00:00 になる @JvmStatic - val DEFAULT = Dateline(true, 11, 0, 0) + val DEFAULT = Dateline(true, 3, 0, 0) } init { diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt similarity index 91% rename from app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt rename to app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt index df9d9dd..bcf9b20 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/gamecontext/GameContext.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt @@ -1,4 +1,4 @@ -package net.averak.gsync.core.gamecontext +package net.averak.gsync.core.game_context import net.averak.gsync.core.daterange.DateRange import net.averak.gsync.core.daterange.Dateline diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt index e584e18..7ff3760 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt @@ -1,6 +1,6 @@ package net.averak.gsync.core.logger -import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.core.game_context.GameContext import net.logstash.logback.argument.StructuredArgument import net.logstash.logback.argument.StructuredArguments import org.slf4j.LoggerFactory diff --git a/app/core/src/test/groovy/net/averak/gsync/core/daterange/DateRange_UT.groovy b/app/core/src/test/groovy/net/averak/gsync/core/daterange/DateRange_UT.groovy new file mode 100644 index 0000000..26135e5 --- /dev/null +++ b/app/core/src/test/groovy/net/averak/gsync/core/daterange/DateRange_UT.groovy @@ -0,0 +1,71 @@ +package net.averak.gsync.core.daterange + +import net.averak.gsync.testkit.AbstractSpec + +import java.time.LocalDate +import java.time.LocalDateTime + +class DateRange_UT extends AbstractSpec { + + def "constructor: 日時、Datelineからインスタンスを作成できる"() { + when: + final result = new DateRange(time, dateline) + + then: + result == expectedResult + + where: + time | dateline || expectedResult + LocalDateTime.of(2000, 1, 1, 0, 0, 0) | new Dateline(false, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 0, 0, 0), LocalDateTime.of(2000, 1, 2, 0, 0, 0)) + LocalDateTime.of(2000, 1, 1, 23, 59, 59) | new Dateline(false, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 0, 0, 0), LocalDateTime.of(2000, 1, 2, 0, 0, 0)) + } + + def "constructor: 日時からインスタンスを作成できる (デフォルトのDatelineが適応される)"() { + when: + final result = new DateRange(time) + + then: + result == expectedResult + + where: + time || expectedResult + LocalDateTime.of(2000, 1, 1, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(1999, 12, 31, 21, 0, 0), LocalDateTime.of(2000, 1, 1, 21, 0, 0)) + LocalDateTime.of(2000, 1, 1, 12, 59, 59) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(1999, 12, 31, 21, 0, 0), LocalDateTime.of(2000, 1, 1, 21, 0, 0)) + } + + def "includes: 指定された日時が含まれるか判定"() { + given: + final dateRange = new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 13, 0, 0), LocalDateTime.of(2000, 1, 2, 13, 0, 0)) + + when: + final result = dateRange.includes(time) + + then: + result == expectedResult + + where: + time || expectedResult + LocalDateTime.of(2000, 1, 1, 12, 59, 59) || false + LocalDateTime.of(2000, 1, 1, 13, 0, 0) || true + LocalDateTime.of(2000, 1, 2, 12, 59, 59) || true + LocalDateTime.of(2000, 1, 2, 13, 0, 0) || false + } + + def "addDays: 日数を加減算できる"() { + given: + final dateRange = new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 13, 0, 0), LocalDateTime.of(2000, 1, 2, 13, 0, 0)) + + when: + final result = dateRange.addDays(n) + + then: + result == expectedResult + + where: + n || expectedResult + -1 || new DateRange(LocalDate.of(1999, 12, 31), LocalDateTime.of(1999, 12, 31, 13, 0, 0), LocalDateTime.of(2000, 1, 1, 13, 0, 0)) + 0 || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 13, 0, 0), LocalDateTime.of(2000, 1, 2, 13, 0, 0)) + 1 || new DateRange(LocalDate.of(2000, 1, 2), LocalDateTime.of(2000, 1, 2, 13, 0, 0), LocalDateTime.of(2000, 1, 3, 13, 0, 0)) + } + +} diff --git a/app/core/src/test/groovy/net/averak/gsync/core/daterange/Dateline_UT.groovy b/app/core/src/test/groovy/net/averak/gsync/core/daterange/Dateline_UT.groovy new file mode 100644 index 0000000..909e427 --- /dev/null +++ b/app/core/src/test/groovy/net/averak/gsync/core/daterange/Dateline_UT.groovy @@ -0,0 +1,76 @@ +package net.averak.gsync.core.daterange + +import net.averak.gsync.testkit.AbstractSpec + +import java.time.LocalDate +import java.time.LocalDateTime + +class Dateline_UT extends AbstractSpec { + + def "constructor: -12:00:00 ~ +12:00:00 の間で作成できる"() { + when: + final result = new Dateline(isMinus, hour, minute, second) + + then: + result.isMinus() == isMinus + result.hour == hour + result.minute == minute + result.second == second + + where: + isMinus | hour | minute | second + true | 0 | 0 | 0 + true | 12 | 0 | 0 + false | 0 | 0 | 0 + false | 12 | 0 | 0 + } + + def "constructor: 日時の範囲が不正な場合は例外を返す"() { + when: + new Dateline(isMinus, hour, minute, second) + + then: + final exception = thrown(IllegalArgumentException) + exception.message == expectedExceptionMessage + + then: + where: + isMinus | hour | minute | second || expectedExceptionMessage + true | 13 | 0 | 0 || "time must be -12:00:00 ~ +12:00:00, but was -13:00:00" + true | 12 | 1 | 0 || "time must be -12:00:00 ~ +12:00:00, but was -12:01:00" + true | 12 | 0 | 1 || "time must be -12:00:00 ~ +12:00:00, but was -12:00:01" + false | 13 | 0 | 0 || "time must be -12:00:00 ~ +12:00:00, but was +13:00:00" + false | 12 | 1 | 0 || "time must be -12:00:00 ~ +12:00:00, but was +12:01:00" + false | 12 | 0 | 1 || "time must be -12:00:00 ~ +12:00:00, but was +12:00:01" + } + + def "getDateRangeOnDate: 日付から DateRange を取得できる"() { + when: + final result = dateline.getDateRangeOnDate(on) + + then: + result == expectedResult + + where: + dateline | on || expectedResult + new Dateline(false, 0, 0, 0) | LocalDate.of(2000, 1, 1) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 0, 0, 0), LocalDateTime.of(2000, 1, 2, 0, 0, 0)) + new Dateline(false, 0, 0, 0) | LocalDate.of(2000, 1, 2) || new DateRange(LocalDate.of(2000, 1, 2), LocalDateTime.of(2000, 1, 2, 0, 0, 0), LocalDateTime.of(2000, 1, 3, 0, 0, 0)) + new Dateline(true, 1, 0, 0) | LocalDate.of(2000, 1, 1) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(1999, 12, 31, 23, 0, 0), LocalDateTime.of(2000, 1, 1, 23, 0, 0)) + } + + def "getDateRangeAtTime: 日時から DateRange を取得できる"() { + when: + final result = dateline.getDateRangeAtTime(at) + + then: + result == expectedResult + + where: + dateline | at || expectedResult + new Dateline(false, 0, 0, 0) | LocalDateTime.of(2000, 1, 1, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 0, 0, 0), LocalDateTime.of(2000, 1, 2, 0, 0, 0)) + new Dateline(false, 0, 0, 0) | LocalDateTime.of(2000, 1, 1, 23, 59, 59) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(2000, 1, 1, 0, 0, 0), LocalDateTime.of(2000, 1, 2, 0, 0, 0)) + new Dateline(false, 0, 0, 0) | LocalDateTime.of(2000, 1, 2, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 2), LocalDateTime.of(2000, 1, 2, 0, 0, 0), LocalDateTime.of(2000, 1, 3, 0, 0, 0)) + new Dateline(true, 1, 0, 0) | LocalDateTime.of(2000, 1, 1, 0, 0, 0) || new DateRange(LocalDate.of(2000, 1, 1), LocalDateTime.of(1999, 12, 31, 23, 0, 0), LocalDateTime.of(2000, 1, 1, 23, 0, 0)) + } + +} diff --git a/app/core/src/test/groovy/net/averak/gsync/core/game_context/GameContext_UT.groovy b/app/core/src/test/groovy/net/averak/gsync/core/game_context/GameContext_UT.groovy new file mode 100644 index 0000000..f2a62ad --- /dev/null +++ b/app/core/src/test/groovy/net/averak/gsync/core/game_context/GameContext_UT.groovy @@ -0,0 +1,33 @@ +package net.averak.gsync.core.game_context + +import net.averak.gsync.core.daterange.DateRange +import net.averak.gsync.core.daterange.Dateline +import net.averak.gsync.testkit.AbstractSpec +import net.averak.gsync.testkit.Faker + +import java.time.LocalDate +import java.time.LocalDateTime + +class GameContext_UT extends AbstractSpec { + + def "getToday: 今日の日付を取得する"() { + given: + final context = new GameContext( + Faker.alphanumeric(), + Faker.uuidv4(), + new Dateline(false, 0, 0, 0), + LocalDateTime.of(2020, 1, 1, 0, 0, 0), + ) + + when: + final result = context.getToday() + + then: + result == new DateRange( + LocalDate.of(2020, 1, 1), + LocalDateTime.of(2020, 1, 1, 0, 0, 0), + LocalDateTime.of(2020, 1, 2, 0, 0, 0), + ) + } + +} diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt new file mode 100644 index 0000000..1d73ac4 --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt @@ -0,0 +1,45 @@ +package net.averak.gsync.infrastructure.mybatis + +import org.mybatis.generator.api.GeneratedXmlFile +import org.mybatis.generator.api.IntrospectedTable +import org.mybatis.generator.api.PluginAdapter +import org.mybatis.generator.api.dom.java.Interface +import org.mybatis.generator.api.dom.java.TopLevelClass + +/** + * MyBatis Generatorで不要なテーブルを無視するプラグイン + */ +class IgnoreTablePlugin : PluginAdapter() { + + private val ignoredTableNames = listOf( + "flyway_schema_history", + "oauth2_authorized_client", + "SPRING_SESSION", + "SPRING_SESSION_ATTRIBUTES", + ) + + override fun validate(warnings: List): Boolean { + return true + } + + private fun checkIsTableToGenerate(introspectedTable: IntrospectedTable): Boolean { + val tableName = introspectedTable.fullyQualifiedTableNameAtRuntime.replace("`", "") + return ignoredTableNames.none { tableName == it } + } + + override fun modelBaseRecordClassGenerated(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable): Boolean { + return checkIsTableToGenerate(introspectedTable) + } + + override fun modelExampleClassGenerated(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable): Boolean { + return checkIsTableToGenerate(introspectedTable) + } + + override fun clientGenerated(interfaze: Interface, introspectedTable: IntrospectedTable): Boolean { + return checkIsTableToGenerate(introspectedTable) + } + + override fun sqlMapGenerated(sqlMap: GeneratedXmlFile, introspectedTable: IntrospectedTable): Boolean { + return checkIsTableToGenerate(introspectedTable) + } +} diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt new file mode 100644 index 0000000..f3a0683 --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt @@ -0,0 +1,31 @@ +package net.averak.gsync.infrastructure.mybatis + +import org.mybatis.generator.api.IntrospectedTable +import org.mybatis.generator.api.PluginAdapter + +/** + * MyBatis Generatorで生成されるファイル名をカスタマイズするプラグイン + */ +class RenameGeneratedFilesPlugin : PluginAdapter() { + + override fun validate(warnings: List): Boolean { + return true + } + + override fun initialized(introspectedTable: IntrospectedTable) { + super.initialized(introspectedTable) + introspectedTable.baseRecordType += "Entity" + introspectedTable.recordWithBLOBsType += "Entity" + + // 生成されたファイルに直接変更を加えるのを避けるために、生成されるファイルを XxxMapper から XxxBaseMapper に変更する + // XxxBaseMapper を継承した XxxMapper が手動で作成されるはず + introspectedTable.myBatis3JavaMapperType = introspectedTable.myBatis3JavaMapperType.replace( + "Mapper$".toRegex(), + "BaseMapper", + ) + introspectedTable.myBatis3XmlMapperFileName = introspectedTable.myBatis3XmlMapperFileName.replace( + "Mapper\\.xml".toRegex(), + "BaseMapper.xml", + ) + } +} diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt new file mode 100644 index 0000000..62c9b76 --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt @@ -0,0 +1,57 @@ +package net.averak.gsync.infrastructure.mybatis + +import org.mybatis.generator.api.IntrospectedTable +import org.mybatis.generator.api.PluginAdapter +import org.mybatis.generator.api.dom.java.FullyQualifiedJavaType +import org.mybatis.generator.api.dom.java.TopLevelClass + +/** + * NULL 許容/非許容なカラムに対して Nullable/Nonnull アノテーションを付与するプラグイン + */ +class ResolveNullPlugin : PluginAdapter() { + + override fun validate(warnings: List): Boolean { + return true + } + + override fun modelBaseRecordClassGenerated(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable): Boolean { + resolveNullable(topLevelClass, introspectedTable) + return true + } + + override fun modelPrimaryKeyClassGenerated(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable): Boolean { + return modelBaseRecordClassGenerated(topLevelClass, introspectedTable) + } + + override fun modelRecordWithBLOBsClassGenerated(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable): Boolean { + return modelBaseRecordClassGenerated(topLevelClass, introspectedTable) + } + + private fun resolveNullable(topLevelClass: TopLevelClass, introspectedTable: IntrospectedTable) { + topLevelClass.addImportedType(FullyQualifiedJavaType("javax.annotation.Nullable")) + topLevelClass.addImportedType(FullyQualifiedJavaType("javax.annotation.Nonnull")) + + val columnNullableMap = HashMap() + for (i in 0.. + topLevelClass.methods.forEach { method -> + if (method.name.startsWith(("get")) && method.name.substring(3).equals(columnName, ignoreCase = true)) { + method.addAnnotation(getAnnotationName(isNullable)) + } + method.parameters.forEach { parameter -> + if (parameter.name == columnName) { + parameter.addAnnotation(getAnnotationName(isNullable)) + } + } + } + } + } + + private fun getAnnotationName(isNullable: Boolean): String { + return if (isNullable) "@Nullable" else "@Nonnull" + } +} diff --git a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt index 68c015e..c94b85d 100644 --- a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -1,6 +1,6 @@ package net.averak.gsync.usecase -import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.core.game_context.GameContext import net.averak.gsync.domain.model.Echo import net.averak.gsync.domain.repository.IEchoRepository import net.averak.gsync.usecase.transaction.ITransaction diff --git a/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy b/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy index d4ea003..efdac6a 100644 --- a/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -1,6 +1,11 @@ package net.averak.gsync.usecase +import net.averak.gsync.domain.repository.IEchoRepository import net.averak.gsync.testkit.AbstractSpec +import org.spockframework.spring.SpringBean abstract class AbstractUsecase_UT extends AbstractSpec { + + @SpringBean + IEchoRepository echoRepository = Mock() } diff --git a/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy index ee7c736..f213d3b 100644 --- a/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -1,6 +1,6 @@ package net.averak.gsync.usecase -import net.averak.gsync.core.gamecontext.GameContext +import net.averak.gsync.core.game_context.GameContext import net.averak.gsync.testkit.Faker import org.springframework.beans.factory.annotation.Autowired @@ -18,6 +18,7 @@ class EchoUsecase_UT extends AbstractUsecase_UT { final result = this.sut.echo(gctx, message) then: + 1 * this.echoRepository.save(_) result.timestamp == gctx.currentTime result.message == message } diff --git a/build.gradle.kts b/build.gradle.kts index 17d2ee9..9c03b9a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -18,6 +18,8 @@ buildscript { dependencies { classpath(libs.spring.boot.gradle.plugin) classpath(libs.flyway.gradle.plugin) + classpath(libs.flyway.spanner) + classpath(libs.google.cloud.spanner.jdbc) } } @@ -72,6 +74,11 @@ allprojects { ) } + java { + targetExclude("build/**") + eclipse() + } + groovy { targetExclude("build/**") } @@ -82,7 +89,7 @@ allprojects { property("sonar.projectKey", "averak_gsync") property("sonar.organization", "averak") property("sonar.host.url", "https://sonarcloud.io") - property("sonar.exclusions", "testkit/**") + property("sonar.exclusions", "testkit/**,**/dto/*,**/entity/base/*,**/mapper/base/*") } } @@ -133,6 +140,7 @@ project(":adapter") { implementation(rootProject.libs.spring.boot.starter.data.jpa) implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.google.cloud.spanner.hibernate) + implementation(rootProject.libs.mybatis.spring.boot.starter) testImplementation(rootProject.libs.spring.boot.starter.test) } @@ -159,6 +167,7 @@ project(":infrastructure") { implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.jackson.module.kotlin) implementation(rootProject.libs.jackson.datatype.jsr310) + implementation(rootProject.libs.mybatis.generator.maven.plugin) } } @@ -179,8 +188,10 @@ project(":testkit") { implementation(project(":infrastructure")) implementation(project(":usecase")) implementation(rootProject.libs.spring.boot.starter.test) + implementation(rootProject.libs.spring.boot.starter.data.jpa) implementation(rootProject.libs.spring.boot.starter.data.redis) implementation(rootProject.libs.commons.lang3) + implementation(rootProject.libs.flyway.core) api(rootProject.libs.spock.core) api(rootProject.libs.spock.spring) @@ -209,6 +220,11 @@ dependencies { implementation(libs.flyway.spanner) } +flyway { + url = "jdbc:cloudspanner://localhost:9010/projects/gsync-sandbox/instances/sandbox/databases/sandbox?autoConfigEmulator=true" + cleanDisabled = false +} + gitProperties { val stdout = ByteArrayOutputStream() project.exec { @@ -220,3 +236,30 @@ gitProperties { customProperty("git.commit.id.describe", version) gitPropertiesResourceDir = file("$rootDir/build/git/src/main/resources") } + +tasks { + val mybatisGenerator: Configuration by configurations.creating + dependencies { + mybatisGenerator(project(":infrastructure")) + mybatisGenerator(libs.mybatis.generator.core) + mybatisGenerator(libs.google.cloud.spanner.jdbc) + } + register("mbgenerate", Task::class) { + doLast { + ant.withGroovyBuilder { + "taskdef"( + "name" to "mbgenerator", + "classname" to "org.mybatis.generator.ant.GeneratorAntTask", + "classpath" to mybatisGenerator.asPath, + ) + } + ant.withGroovyBuilder { + "mbgenerator"( + "overwrite" to true, + "configfile" to "$rootDir/src/main/resources/mybatis-generator-config.xml", + "verbose" to true, + ) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b9aa54f..7f661e5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ easy-random = "6.2.1" flyway = "10.4.1" groovy = "4.0.7" kotlin = "1.9.20" +mybatis-generator = "1.4.2" spock = "2.4-M1-groovy-4.0" spring-boot = "3.2.1" @@ -19,6 +20,9 @@ jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16 jackson-datatype-jsr310 = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1" logback-classic = "ch.qos.logback:logback-classic:1.4.14" logstash-logback-encoder = "net.logstash.logback:logstash-logback-encoder:7.4" +mybatis-generator-core = { module = "org.mybatis.generator:mybatis-generator-core", version.ref = "mybatis-generator" } +mybatis-generator-maven-plugin = { module = "org.mybatis.generator:mybatis-generator-maven-plugin", version.ref = "mybatis-generator" } +mybatis-spring-boot-starter = "org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3" spock-core = { module = "org.spockframework:spock-core", version.ref = "spock" } spock-spring = { module = "org.spockframework:spock-spring", version.ref = "spock" } spring-boot-gradle-plugin = { module = "org.springframework.boot:spring-boot-gradle-plugin", version.ref = "spring-boot" } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b484364..282355b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -40,7 +40,7 @@ spring: baseline-on-migrate: true validate-on-migrate: false outOfOrder: false - locations: classpath:/migration + locations: classpath:/db/migration connect-retries: 5 server: @@ -50,6 +50,13 @@ server: charset: UTF-8 force: true +mybatis: + configuration: + map-underscore-to-camel-case: true + mapperLocations: + - classpath:/dao/base/*.xml + - classpath:/dao/extend/*.xml + gsync: version: ${git.commit.id.describe} debug: ${IS_DEBUG:false} diff --git a/src/main/resources/dao/base/EchoBaseMapper.xml b/src/main/resources/dao/base/EchoBaseMapper.xml new file mode 100644 index 0000000..7bbb368 --- /dev/null +++ b/src/main/resources/dao/base/EchoBaseMapper.xml @@ -0,0 +1,261 @@ + + + + + + + + + + + + + + + + + + + + + and ${criterion.condition} + + + and ${criterion.condition} #{criterion.value} + + + and ${criterion.condition} #{criterion.value} and #{criterion.secondValue} + + + and ${criterion.condition} + + #{listItem} + + + + + + + + + + + + + + + + + + + and ${criterion.condition} + + + and ${criterion.condition} #{criterion.value} + + + and ${criterion.condition} #{criterion.value} and #{criterion.secondValue} + + + and ${criterion.condition} + + #{listItem} + + + + + + + + + + + + `echo_id`, `message`, `timestamp` + + + + + + delete from `gsync_echo` + where `echo_id` = #{echoId,jdbcType=NVARCHAR} + + + + delete from `gsync_echo` + + + + + + + insert into `gsync_echo` (`echo_id`, `message`, `timestamp` + ) + values (#{echoId,jdbcType=NVARCHAR}, #{message,jdbcType=NVARCHAR}, #{timestamp,jdbcType=TIMESTAMP} + ) + + + + insert into `gsync_echo` + + + `echo_id`, + + + `message`, + + + `timestamp`, + + + + + #{echoId,jdbcType=NVARCHAR}, + + + #{message,jdbcType=NVARCHAR}, + + + #{timestamp,jdbcType=TIMESTAMP}, + + + + + + + update `gsync_echo` + + + `echo_id` = #{row.echoId,jdbcType=NVARCHAR}, + + + `message` = #{row.message,jdbcType=NVARCHAR}, + + + `timestamp` = #{row.timestamp,jdbcType=TIMESTAMP}, + + + + + + + + + update `gsync_echo` + set `echo_id` = #{row.echoId,jdbcType=NVARCHAR}, + `message` = #{row.message,jdbcType=NVARCHAR}, + `timestamp` = #{row.timestamp,jdbcType=TIMESTAMP} + + + + + + + update `gsync_echo` + + + `message` = #{message,jdbcType=NVARCHAR}, + + + `timestamp` = #{timestamp,jdbcType=TIMESTAMP}, + + + where `echo_id` = #{echoId,jdbcType=NVARCHAR} + + + + update `gsync_echo` + set `message` = #{message,jdbcType=NVARCHAR}, + `timestamp` = #{timestamp,jdbcType=TIMESTAMP} + where `echo_id` = #{echoId,jdbcType=NVARCHAR} + + + \ No newline at end of file diff --git a/src/main/resources/dao/extend/EchoMapper.xml b/src/main/resources/dao/extend/EchoMapper.xml new file mode 100644 index 0000000..1345e1d --- /dev/null +++ b/src/main/resources/dao/extend/EchoMapper.xml @@ -0,0 +1,4 @@ + + + + diff --git a/src/main/resources/db/migration/V1_0_0_0__init_schema.sql b/src/main/resources/db/migration/V1_0_0_0__init_schema.sql new file mode 100644 index 0000000..d942378 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_0_0__init_schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE gsync_echo +( + echo_id STRING(36) NOT NULL, + message STRING(255) NOT NULL, + timestamp TIMESTAMP NOT NULL, +) PRIMARY KEY (echo_id); diff --git a/src/main/resources/migration/V1_0_0_0__init_schema.sql b/src/main/resources/migration/V1_0_0_0__init_schema.sql deleted file mode 100644 index 8afa037..0000000 --- a/src/main/resources/migration/V1_0_0_0__init_schema.sql +++ /dev/null @@ -1,10 +0,0 @@ -START BATCH DDL; - -CREATE TABLE echo -( - id STRING(36) NOT NULL, - message STRING(255) NOT NULL, - timestamp TIMESTAMP NOT NULL, -) PRIMARY KEY (id); - -RUN BATCH; diff --git a/src/main/resources/mybatis-generator-config.xml b/src/main/resources/mybatis-generator-config.xml new file mode 100644 index 0000000..2b2bb47 --- /dev/null +++ b/src/main/resources/mybatis-generator-config.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 5a1d3a1..d436fcf 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -2,6 +2,8 @@ spring: data: redis: database: 1 + flyway: + clean-disabled: false gsync: debug: true diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index d67b59c..f1eafcf 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -6,7 +6,7 @@ - + diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy index 1433ccb..04fc8e8 100644 --- a/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy @@ -2,6 +2,7 @@ package net.averak.gsync.testkit import net.averak.gsync.core.exception.GsyncException +import java.sql.Timestamp import java.time.Duration import java.time.LocalDateTime import java.time.temporal.ChronoUnit @@ -19,12 +20,13 @@ class Assert { } /** - * LocalDateTime が一致するか判定 + * タイムスタンプが一致するか検証 * * @param approxDuration 許容する誤差 */ - static void localDateTimeIs(final LocalDateTime actual, final LocalDateTime expected, final Duration approxDuration = Duration.ofSeconds(5)) { - assert ChronoUnit.MILLIS.between(actual as LocalDateTime, expected) <= approxDuration.toMillis() + static void timestampIs(final Object actual, final LocalDateTime expected, final Duration approxDuration = Duration.ofSeconds(5)) { + assert actual instanceof Timestamp + assert ChronoUnit.MILLIS.between(actual.toLocalDateTime(), expected) <= approxDuration.toMillis() } } diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt index 0bae3ef..7a11ff1 100644 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt @@ -3,6 +3,7 @@ package net.averak.gsync.testkit import groovy.sql.Sql import jakarta.annotation.PostConstruct import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.boot.test.context.TestConfiguration @@ -17,12 +18,31 @@ internal open class TestConfig( private val randomizers: List>, ) { + companion object { + + private var isAlreadyFlywayMigrated = false + } + @PostConstruct open fun init() { TimeZone.setDefault(TimeZone.getTimeZone("UTC")) Faker.init(randomizers) } + @Bean + open fun flywayMigrationStrategy(): FlywayMigrationStrategy { + return FlywayMigrationStrategy { flyway -> + // テスト開始時に既存のデータベースをクリーンアップできれば十分なので、マイグレーションは一度だけ実行する + if (isAlreadyFlywayMigrated) { + return@FlywayMigrationStrategy + } + + flyway.clean() + flyway.migrate() + isAlreadyFlywayMigrated = true + } + } + @Bean open fun dataSource(properties: DataSourceProperties): DataSource { return TransactionAwareDataSourceProxy( From 799e22d84cf8435173f1541c40303f1850b34ccb Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 07:23:26 +0900 Subject: [PATCH 23/26] =?UTF-8?q?refs=20#8=20Dockerfile=E3=82=92=E4=BD=9C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 13 +++++-------- Dockerfile | 12 ++++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) create mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df4459f..4bb22df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,12 +67,9 @@ jobs: steps: - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 - with: - distribution: corretto - java-version: 17 - cache: gradle - - name: backend build - run: | - ./gradlew quarkusBuild \ No newline at end of file + - name: build docker image + uses: docker/build-push-action@v3 + with: + context: . + push: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..179216d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM public.ecr.aws/docker/library/amazoncorretto:17 as build-stage + +WORKDIR /app +COPY . /app/ + +RUN yum install -y git +RUN ./gradlew build -x test + +FROM public.ecr.aws/docker/library/amazoncorretto:17 + +COPY --from=build-stage /app/build/libs/*.jar app.jar +ENTRYPOINT ["java","-jar","app.jar"] From 07bc7fc1ceb008c90191adfec474aef946383ebd Mon Sep 17 00:00:00 2001 From: averak Date: Tue, 9 Jan 2024 07:50:16 +0900 Subject: [PATCH 24/26] =?UTF-8?q?refs=20#8=20=E3=83=9E=E3=82=B8=E3=83=83?= =?UTF-8?q?=E3=82=AF=E3=82=AB=E3=83=A9=E3=83=A0=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/dao/entity/base/EchoEntity.java | 83 +++++++++- .../adapter/dao/entity/base/EchoExample.java | 142 ++++++++++++++++-- .../adapter/handler/rest/HttpRequestScope.kt | 7 +- .../rest/interceptor/AccessLogInterceptor.kt | 2 +- .../interceptor/GameContextInterceptor.kt | 2 +- .../adapter/repository/EchoRepository.kt | 37 ++++- .../repository/AbstractRepository_UT.groovy | 6 + .../repository/EchoRepository_UT.groovy | 79 ++++++++++ .../gsync/core/game_context/GameContext.kt | 3 +- .../net/averak/gsync/core/logger/Logger.kt | 2 +- .../domain/repository/IEchoRepository.kt | 8 +- .../mybatis/{ => plugin}/IgnoreTablePlugin.kt | 2 +- .../RenameGeneratedFilesPlugin.kt | 2 +- .../mybatis/{ => plugin}/ResolveNullPlugin.kt | 2 +- .../type_handler/LocalDateTimeTypeHandler.kt | 33 ++++ .../type_handler/LocalDateTypeHandler.kt | 33 ++++ .../net/averak/gsync/usecase/EchoUsecase.kt | 2 +- .../gsync/usecase/EchoUsecase_UT.groovy | 2 +- build.gradle.kts | 1 + src/main/resources/application.yaml | 1 + .../resources/dao/base/EchoBaseMapper.xml | 46 +++++- .../db/migration/V1_0_0_0__init_schema.sql | 3 + .../resources/mybatis-generator-config.xml | 10 +- .../gsync/testkit/AbstractDatabaseSpec.groovy | 4 + .../randomizer/EntityRandomizers.groovy | 25 +++ .../kotlin/net/averak/gsync/testkit/Faker.kt | 16 +- .../net/averak/gsync/testkit/Fixture.kt | 21 ++- 27 files changed, 517 insertions(+), 57 deletions(-) create mode 100644 app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/AbstractRepository_UT.groovy create mode 100644 app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/EchoRepository_UT.groovy rename app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/{ => plugin}/IgnoreTablePlugin.kt (96%) rename app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/{ => plugin}/RenameGeneratedFilesPlugin.kt (95%) rename app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/{ => plugin}/ResolveNullPlugin.kt (97%) create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTimeTypeHandler.kt create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTypeHandler.kt create mode 100644 testkit/src/main/groovy/net/averak/gsync/testkit/randomizer/EntityRandomizers.groovy diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java index cd5acc2..99c8e19 100644 --- a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java @@ -1,6 +1,6 @@ package net.averak.gsync.adapter.dao.entity.base; -import java.util.Date; +import java.time.LocalDateTime; import javax.annotation.Nonnull; import javax.annotation.Nullable; @@ -30,7 +30,25 @@ public class EchoEntity { * * @mbg.generated */ - private Date timestamp; + private LocalDateTime timestamp; + + /** + * + * This field was generated by MyBatis Generator. This field corresponds to the + * database column gsync_echo.created_at + * + * @mbg.generated + */ + private LocalDateTime createdAt; + + /** + * + * This field was generated by MyBatis Generator. This field corresponds to the + * database column gsync_echo.updated_at + * + * @mbg.generated + */ + private LocalDateTime updatedAt; /** * This method was generated by MyBatis Generator. This method corresponds to @@ -38,10 +56,13 @@ public class EchoEntity { * * @mbg.generated */ - public EchoEntity(@Nonnull String echoId, @Nonnull String message, @Nonnull Date timestamp) { + public EchoEntity(@Nonnull String echoId, @Nonnull String message, @Nonnull LocalDateTime timestamp, + @Nonnull LocalDateTime createdAt, @Nonnull LocalDateTime updatedAt) { this.echoId = echoId; this.message = message; this.timestamp = timestamp; + this.createdAt = createdAt; + this.updatedAt = updatedAt; } /** @@ -115,7 +136,7 @@ public void setMessage(@Nonnull String message) { * @mbg.generated */ @Nonnull - public Date getTimestamp() { + public LocalDateTime getTimestamp() { return timestamp; } @@ -128,7 +149,59 @@ public Date getTimestamp() { * * @mbg.generated */ - public void setTimestamp(@Nonnull Date timestamp) { + public void setTimestamp(@Nonnull LocalDateTime timestamp) { this.timestamp = timestamp; } + + /** + * This method was generated by MyBatis Generator. This method returns the value + * of the database column gsync_echo.created_at + * + * @return the value of gsync_echo.created_at + * + * @mbg.generated + */ + @Nonnull + public LocalDateTime getCreatedAt() { + return createdAt; + } + + /** + * This method was generated by MyBatis Generator. This method sets the value of + * the database column gsync_echo.created_at + * + * @param createdAt + * the value for gsync_echo.created_at + * + * @mbg.generated + */ + public void setCreatedAt(@Nonnull LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + /** + * This method was generated by MyBatis Generator. This method returns the value + * of the database column gsync_echo.updated_at + * + * @return the value of gsync_echo.updated_at + * + * @mbg.generated + */ + @Nonnull + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + /** + * This method was generated by MyBatis Generator. This method sets the value of + * the database column gsync_echo.updated_at + * + * @param updatedAt + * the value for gsync_echo.updated_at + * + * @mbg.generated + */ + public void setUpdatedAt(@Nonnull LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } } \ No newline at end of file diff --git a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java index fe85cfe..2b11e54 100644 --- a/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java @@ -1,7 +1,7 @@ package net.averak.gsync.adapter.dao.entity.base; +import java.time.LocalDateTime; import java.util.ArrayList; -import java.util.Date; import java.util.List; public class EchoExample { @@ -345,55 +345,175 @@ public Criteria andTimestampIsNotNull() { return (Criteria) this; } - public Criteria andTimestampEqualTo(Date value) { + public Criteria andTimestampEqualTo(LocalDateTime value) { addCriterion("`timestamp` =", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampNotEqualTo(Date value) { + public Criteria andTimestampNotEqualTo(LocalDateTime value) { addCriterion("`timestamp` <>", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampGreaterThan(Date value) { + public Criteria andTimestampGreaterThan(LocalDateTime value) { addCriterion("`timestamp` >", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampGreaterThanOrEqualTo(Date value) { + public Criteria andTimestampGreaterThanOrEqualTo(LocalDateTime value) { addCriterion("`timestamp` >=", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampLessThan(Date value) { + public Criteria andTimestampLessThan(LocalDateTime value) { addCriterion("`timestamp` <", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampLessThanOrEqualTo(Date value) { + public Criteria andTimestampLessThanOrEqualTo(LocalDateTime value) { addCriterion("`timestamp` <=", value, "timestamp"); return (Criteria) this; } - public Criteria andTimestampIn(List values) { + public Criteria andTimestampIn(List values) { addCriterion("`timestamp` in", values, "timestamp"); return (Criteria) this; } - public Criteria andTimestampNotIn(List values) { + public Criteria andTimestampNotIn(List values) { addCriterion("`timestamp` not in", values, "timestamp"); return (Criteria) this; } - public Criteria andTimestampBetween(Date value1, Date value2) { + public Criteria andTimestampBetween(LocalDateTime value1, LocalDateTime value2) { addCriterion("`timestamp` between", value1, value2, "timestamp"); return (Criteria) this; } - public Criteria andTimestampNotBetween(Date value1, Date value2) { + public Criteria andTimestampNotBetween(LocalDateTime value1, LocalDateTime value2) { addCriterion("`timestamp` not between", value1, value2, "timestamp"); return (Criteria) this; } + + public Criteria andCreatedAtIsNull() { + addCriterion("`created_at` is null"); + return (Criteria) this; + } + + public Criteria andCreatedAtIsNotNull() { + addCriterion("`created_at` is not null"); + return (Criteria) this; + } + + public Criteria andCreatedAtEqualTo(LocalDateTime value) { + addCriterion("`created_at` =", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtNotEqualTo(LocalDateTime value) { + addCriterion("`created_at` <>", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtGreaterThan(LocalDateTime value) { + addCriterion("`created_at` >", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtGreaterThanOrEqualTo(LocalDateTime value) { + addCriterion("`created_at` >=", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtLessThan(LocalDateTime value) { + addCriterion("`created_at` <", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtLessThanOrEqualTo(LocalDateTime value) { + addCriterion("`created_at` <=", value, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtIn(List values) { + addCriterion("`created_at` in", values, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtNotIn(List values) { + addCriterion("`created_at` not in", values, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtBetween(LocalDateTime value1, LocalDateTime value2) { + addCriterion("`created_at` between", value1, value2, "createdAt"); + return (Criteria) this; + } + + public Criteria andCreatedAtNotBetween(LocalDateTime value1, LocalDateTime value2) { + addCriterion("`created_at` not between", value1, value2, "createdAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtIsNull() { + addCriterion("`updated_at` is null"); + return (Criteria) this; + } + + public Criteria andUpdatedAtIsNotNull() { + addCriterion("`updated_at` is not null"); + return (Criteria) this; + } + + public Criteria andUpdatedAtEqualTo(LocalDateTime value) { + addCriterion("`updated_at` =", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtNotEqualTo(LocalDateTime value) { + addCriterion("`updated_at` <>", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtGreaterThan(LocalDateTime value) { + addCriterion("`updated_at` >", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtGreaterThanOrEqualTo(LocalDateTime value) { + addCriterion("`updated_at` >=", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtLessThan(LocalDateTime value) { + addCriterion("`updated_at` <", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtLessThanOrEqualTo(LocalDateTime value) { + addCriterion("`updated_at` <=", value, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtIn(List values) { + addCriterion("`updated_at` in", values, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtNotIn(List values) { + addCriterion("`updated_at` not in", values, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtBetween(LocalDateTime value1, LocalDateTime value2) { + addCriterion("`updated_at` between", value1, value2, "updatedAt"); + return (Criteria) this; + } + + public Criteria andUpdatedAtNotBetween(LocalDateTime value1, LocalDateTime value2) { + addCriterion("`updated_at` not between", value1, value2, "updatedAt"); + return (Criteria) this; + } } /** diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt index eb282e0..4bbce14 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt @@ -6,6 +6,7 @@ import net.averak.gsync.core.game_context.GameContext import org.springframework.stereotype.Component import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.* @Component class HttpRequestScope( @@ -50,8 +51,10 @@ class HttpRequestScope( return httpServletRequest.getHeader(HeaderName.CLIENT_VERSION.key) } - fun getIdempotencyKey(): String? { - return httpServletRequest.getHeader(HeaderName.IDEMPOTENCY_KEY.key) + fun getIdempotencyKey(): UUID? { + return httpServletRequest.getHeader(HeaderName.IDEMPOTENCY_KEY.key)?.let { + UUID.fromString(it) + } } fun getSpoofingCurrentTime(): LocalDateTime? { diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt index 64c9f6f..daf411b 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/AccessLogInterceptor.kt @@ -31,7 +31,7 @@ class AccessLogInterceptor( mapOf( "http_request" to mapOf( "client_version" to requestScope.getClientVersion(), - "idempotency_key" to gctx.idempotencyKey, + "idempotency_key" to gctx.idempotencyKey.toString(), "requested_at" to gctx.currentTime.toString(), "elapsed_ms" to Duration.between(gctx.currentTime, LocalDateTime.now()).toMillis(), "status_code" to HttpStatusCode.valueOf(response.status), diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt index 22b8d14..fe87332 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/interceptor/GameContextInterceptor.kt @@ -22,7 +22,7 @@ open class GameContextInterceptor( val gctx = GameContext( config.version, // クライアントが Idempotency-Key を必ず設定してくるとは限らないので、未設定の場合はサーバ側でユニークキーを発行し、毎回異なるリクエストとして扱う - requestScope.getIdempotencyKey() ?: UUID.randomUUID().toString(), + requestScope.getIdempotencyKey() ?: UUID.randomUUID(), Dateline.DEFAULT, spoofingCurrentTime ?: LocalDateTime.now(), ) diff --git a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt index 150b805..01c40df 100644 --- a/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt @@ -2,10 +2,10 @@ package net.averak.gsync.adapter.repository import net.averak.gsync.adapter.dao.entity.base.EchoEntity import net.averak.gsync.adapter.dao.mapper.extend.EchoMapper +import net.averak.gsync.core.game_context.GameContext import net.averak.gsync.domain.model.Echo import net.averak.gsync.domain.repository.IEchoRepository import org.springframework.stereotype.Repository -import java.time.ZoneOffset import java.util.* @Repository @@ -13,12 +13,33 @@ open class EchoRepository( private val echoMapper: EchoMapper, ) : IEchoRepository { - override fun save(echo: Echo) { - val entity = EchoEntity( - echo.id.toString(), - echo.message, - Date.from(echo.timestamp.toInstant(ZoneOffset.UTC)), - ) - echoMapper.insert(entity) + override fun save(gctx: GameContext, echo: Echo) { + val entity = echoMapper.selectByPrimaryKey(echo.id.toString()) + if (entity == null) { + echoMapper.insert( + EchoEntity( + echo.id.toString(), + echo.message, + echo.timestamp, + gctx.currentTime, + gctx.currentTime, + ), + ) + } else { + entity.message = echo.message + entity.timestamp = echo.timestamp + entity.updatedAt = gctx.currentTime + echoMapper.updateByPrimaryKey(entity) + } + } + + override fun findByID(gctx: GameContext, id: UUID): Echo? { + return echoMapper.selectByPrimaryKey(id.toString())?.let { + Echo( + UUID.fromString(it.echoId), + it.message, + it.timestamp, + ) + } } } diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/AbstractRepository_UT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/AbstractRepository_UT.groovy new file mode 100644 index 0000000..7147220 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/AbstractRepository_UT.groovy @@ -0,0 +1,6 @@ +package net.averak.gsync.adapter.repository + +import net.averak.gsync.testkit.AbstractDatabaseSpec + +abstract class AbstractRepository_UT extends AbstractDatabaseSpec { +} diff --git a/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/EchoRepository_UT.groovy b/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/EchoRepository_UT.groovy new file mode 100644 index 0000000..31fa8cf --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/repository/EchoRepository_UT.groovy @@ -0,0 +1,79 @@ +package net.averak.gsync.adapter.repository + +import net.averak.gsync.adapter.dao.entity.base.EchoEntity +import net.averak.gsync.core.game_context.GameContext +import net.averak.gsync.domain.model.Echo +import net.averak.gsync.testkit.Assert +import net.averak.gsync.testkit.Faker +import net.averak.gsync.testkit.Fixture +import org.springframework.beans.factory.annotation.Autowired +import spock.lang.Shared + +import java.time.LocalDateTime + +class EchoRepository_UT extends AbstractRepository_UT { + + @Autowired + EchoRepository sut + + @Shared + LocalDateTime now = LocalDateTime.now() + + def "save: PKが存在しない場合は作成される"() { + given: + final echo = Faker.fake(Echo) + + when: + this.sut.save(Faker.fake(GameContext), echo) + + then: + with(sql.rows("SELECT * FROM gsync_echo")) { + it.size() == 1 + it[0].message == echo.message + Assert.timestampIs(it[0].timestamp, echo.timestamp) + } + } + + def "save: PKが存在する場合は更新される"() { + given: + final entity = Fixture.setup(Faker.fake(EchoEntity)) + final echo = new Echo( + UUID.fromString(entity.echoId), + Faker.alphanumeric(), + LocalDateTime.now(), + ) + + when: + this.sut.save(Faker.fake(GameContext), echo) + + then: + with(sql.rows("SELECT * FROM gsync_echo")) { + it.size() == 1 + it[0].message == echo.message + Assert.timestampIs(it[0].timestamp, echo.timestamp) + } + } + + def "findByID: idから検索できる"() { + given: + final entity = new EchoEntity( + Faker.uuidv5("e1").toString(), + "hello", + now, + now, + now, + ) + Fixture.setup(entity) + + when: + final result = this.sut.findByID(Faker.fake(GameContext), id) + + then: + result == expected + + where: + id || expected + Faker.uuidv5("e1") || new Echo(Faker.uuidv5("e1"), "hello", now) + Faker.uuidv5("e2") || null + } +} diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt index bcf9b20..d766be4 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt @@ -3,13 +3,14 @@ package net.averak.gsync.core.game_context import net.averak.gsync.core.daterange.DateRange import net.averak.gsync.core.daterange.Dateline import java.time.LocalDateTime +import java.util.* /** * 機能によらずアプリケーション横断的なコンテキスト */ data class GameContext( val serverVersion: String, - val idempotencyKey: String, + val idempotencyKey: UUID, val dateline: Dateline, val currentTime: LocalDateTime, ) { diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt index 7ff3760..02c8d3f 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/logger/Logger.kt @@ -50,7 +50,7 @@ class Logger { return StructuredArguments.value( "game_context", mapOf( - "idempotencyKey" to gctx.idempotencyKey, + "idempotencyKey" to gctx.idempotencyKey.toString(), "currentTime" to gctx.currentTime.toString(), ), ) diff --git a/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt b/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt index 106dc15..464e4a9 100644 --- a/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt +++ b/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt @@ -1,8 +1,12 @@ package net.averak.gsync.domain.repository +import net.averak.gsync.core.game_context.GameContext import net.averak.gsync.domain.model.Echo +import java.util.* -fun interface IEchoRepository { +interface IEchoRepository { - fun save(echo: Echo) + fun save(gctx: GameContext, echo: Echo) + + fun findByID(gctx: GameContext, id: UUID): Echo? } diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt similarity index 96% rename from app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt rename to app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt index 1d73ac4..4085146 100644 --- a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/IgnoreTablePlugin.kt +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt @@ -1,4 +1,4 @@ -package net.averak.gsync.infrastructure.mybatis +package net.averak.gsync.infrastructure.mybatis.plugin import org.mybatis.generator.api.GeneratedXmlFile import org.mybatis.generator.api.IntrospectedTable diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/RenameGeneratedFilesPlugin.kt similarity index 95% rename from app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt rename to app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/RenameGeneratedFilesPlugin.kt index f3a0683..9fd294a 100644 --- a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/RenameGeneratedFilesPlugin.kt +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/RenameGeneratedFilesPlugin.kt @@ -1,4 +1,4 @@ -package net.averak.gsync.infrastructure.mybatis +package net.averak.gsync.infrastructure.mybatis.plugin import org.mybatis.generator.api.IntrospectedTable import org.mybatis.generator.api.PluginAdapter diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/ResolveNullPlugin.kt similarity index 97% rename from app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt rename to app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/ResolveNullPlugin.kt index 62c9b76..b9b5681 100644 --- a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/ResolveNullPlugin.kt +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/ResolveNullPlugin.kt @@ -1,4 +1,4 @@ -package net.averak.gsync.infrastructure.mybatis +package net.averak.gsync.infrastructure.mybatis.plugin import org.mybatis.generator.api.IntrospectedTable import org.mybatis.generator.api.PluginAdapter diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTimeTypeHandler.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTimeTypeHandler.kt new file mode 100644 index 0000000..e98f04b --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTimeTypeHandler.kt @@ -0,0 +1,33 @@ +package net.averak.gsync.infrastructure.mybatis.type_handler + +import org.apache.ibatis.type.BaseTypeHandler +import org.apache.ibatis.type.JdbcType +import org.apache.ibatis.type.MappedTypes +import java.sql.CallableStatement +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.sql.Timestamp +import java.time.LocalDateTime + +@MappedTypes(LocalDateTime::class) +class LocalDateTimeTypeHandler : BaseTypeHandler() { + + override fun setNonNullParameter(ps: PreparedStatement, i: Int, parameter: LocalDateTime, jdbcType: JdbcType) { + ps.setTimestamp(i, Timestamp.valueOf(parameter)) + } + + override fun getNullableResult(rs: ResultSet, columnName: String): LocalDateTime? { + val timestamp = rs.getTimestamp(columnName) + return timestamp?.toLocalDateTime() + } + + override fun getNullableResult(rs: ResultSet, columnIndex: Int): LocalDateTime? { + val timestamp = rs.getTimestamp(columnIndex) + return timestamp?.toLocalDateTime() + } + + override fun getNullableResult(cs: CallableStatement, columnIndex: Int): LocalDateTime? { + val timestamp = cs.getTimestamp(columnIndex) + return timestamp?.toLocalDateTime() + } +} diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTypeHandler.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTypeHandler.kt new file mode 100644 index 0000000..0bfea7b --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/type_handler/LocalDateTypeHandler.kt @@ -0,0 +1,33 @@ +package net.averak.gsync.infrastructure.mybatis.type_handler + +import org.apache.ibatis.type.BaseTypeHandler +import org.apache.ibatis.type.JdbcType +import org.apache.ibatis.type.MappedTypes +import java.sql.CallableStatement +import java.sql.Date +import java.sql.PreparedStatement +import java.sql.ResultSet +import java.time.LocalDate + +@MappedTypes(LocalDate::class) +class LocalDateTypeHandler : BaseTypeHandler() { + + override fun setNonNullParameter(ps: PreparedStatement, i: Int, parameter: LocalDate, jdbcType: JdbcType) { + ps.setDate(i, Date.valueOf(parameter)) + } + + override fun getNullableResult(rs: ResultSet, columnName: String): LocalDate? { + val date = rs.getDate(columnName) + return date?.toLocalDate() + } + + override fun getNullableResult(rs: ResultSet, columnIndex: Int): LocalDate? { + val date = rs.getDate(columnIndex) + return date?.toLocalDate() + } + + override fun getNullableResult(cs: CallableStatement, columnIndex: Int): LocalDate? { + val date = cs.getDate(columnIndex) + return date?.toLocalDate() + } +} diff --git a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt index c94b85d..e4f4680 100644 --- a/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -15,7 +15,7 @@ class EchoUsecase( fun echo(gctx: GameContext, message: String): Echo { return transaction.beginRwTransaction { val echo = Echo(message, gctx.currentTime) - echoRepository.save(echo) + echoRepository.save(gctx, echo) return@beginRwTransaction echo } } diff --git a/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy index f213d3b..2379021 100644 --- a/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -18,7 +18,7 @@ class EchoUsecase_UT extends AbstractUsecase_UT { final result = this.sut.echo(gctx, message) then: - 1 * this.echoRepository.save(_) + 1 * this.echoRepository.save(gctx, _) result.timestamp == gctx.currentTime result.message == message } diff --git a/build.gradle.kts b/build.gradle.kts index 9c03b9a..e027cdd 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -167,6 +167,7 @@ project(":infrastructure") { implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.jackson.module.kotlin) implementation(rootProject.libs.jackson.datatype.jsr310) + implementation(rootProject.libs.mybatis.spring.boot.starter) implementation(rootProject.libs.mybatis.generator.maven.plugin) } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 282355b..ba89e71 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -56,6 +56,7 @@ mybatis: mapperLocations: - classpath:/dao/base/*.xml - classpath:/dao/extend/*.xml + type-handlers-package: net.averak.gsync.infrastructure.mybatis.type_handler gsync: version: ${git.commit.id.describe} diff --git a/src/main/resources/dao/base/EchoBaseMapper.xml b/src/main/resources/dao/base/EchoBaseMapper.xml index 7bbb368..dde3db9 100644 --- a/src/main/resources/dao/base/EchoBaseMapper.xml +++ b/src/main/resources/dao/base/EchoBaseMapper.xml @@ -9,7 +9,9 @@ - + + + @@ -83,7 +85,7 @@ WARNING - @mbg.generated This element is automatically generated by MyBatis Generator, do not modify. --> - `echo_id`, `message`, `timestamp` + `echo_id`, `message`, `timestamp`, `created_at`, `updated_at` diff --git a/src/main/resources/db/migration/V1_0_0_0__init_schema.sql b/src/main/resources/db/migration/V1_0_0_0__init_schema.sql index d942378..e3e7842 100644 --- a/src/main/resources/db/migration/V1_0_0_0__init_schema.sql +++ b/src/main/resources/db/migration/V1_0_0_0__init_schema.sql @@ -3,4 +3,7 @@ CREATE TABLE gsync_echo echo_id STRING(36) NOT NULL, message STRING(255) NOT NULL, timestamp TIMESTAMP NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, ) PRIMARY KEY (echo_id); +ALTER TABLE gsync_echo ADD ROW DELETION POLICY (OLDER_THAN(timestamp, INTERVAL 1 DAY)); diff --git a/src/main/resources/mybatis-generator-config.xml b/src/main/resources/mybatis-generator-config.xml index 2b2bb47..a7b6271 100644 --- a/src/main/resources/mybatis-generator-config.xml +++ b/src/main/resources/mybatis-generator-config.xml @@ -11,9 +11,9 @@ - - - + + + @@ -23,6 +23,10 @@ connectionURL="jdbc:cloudspanner://localhost:9010/projects/gsync-sandbox/instances/sandbox/databases/sandbox?autoConfigEmulator=true"> + + + + diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy index 9a4e570..7f29062 100644 --- a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy @@ -20,4 +20,8 @@ abstract class AbstractDatabaseSpec extends AbstractSpec { Fixture.init(sql) } + void cleanup() { + // なぜか @Transactional でロールバックされないので、仕方なく DELETE クエリを実行している + sql.execute("DELETE FROM gsync_echo WHERE echo_id IS NOT NULL") + } } diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/randomizer/EntityRandomizers.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/randomizer/EntityRandomizers.groovy new file mode 100644 index 0000000..5f34bff --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/randomizer/EntityRandomizers.groovy @@ -0,0 +1,25 @@ +package net.averak.gsync.testkit.randomizer + +import net.averak.gsync.adapter.dao.entity.base.EchoEntity +import net.averak.gsync.testkit.Faker +import net.averak.gsync.testkit.IRandomizer +import org.springframework.stereotype.Component + +import java.time.LocalDateTime + +@Component +class EchoEntityRandomizer implements IRandomizer { + + final Class typeToGenerate = EchoEntity.class + + @Override + Object getRandomValue() { + return new EchoEntity( + Faker.uuidv4().toString(), + Faker.alphanumeric(255), + Faker.fake(LocalDateTime), + Faker.fake(LocalDateTime), + Faker.fake(LocalDateTime), + ) + } +} diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt index 9ebd65a..cf3d4f5 100644 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -68,9 +68,11 @@ class Faker { */ @JvmStatic fun email(): String { - return "${RandomStringUtils.randomAlphanumeric( - 10, - )}@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) + return "${ + RandomStringUtils.randomAlphanumeric( + 10, + ) + }@${RandomStringUtils.randomAlphanumeric(5)}.com".lowercase(Locale.getDefault()) } /** @@ -147,16 +149,16 @@ class Faker { * UUIDv4を生成する */ @JvmStatic - fun uuidv4(): String { - return UUID.randomUUID().toString() + fun uuidv4(): UUID { + return UUID.randomUUID() } /** * UUIDv5を生成する */ @JvmStatic - fun uuidv5(name: String): String { - return UUID.nameUUIDFromBytes(name.toByteArray()).toString() + fun uuidv5(name: String): UUID { + return UUID.nameUUIDFromBytes(name.toByteArray()) } } } diff --git a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt index 9671164..293644d 100644 --- a/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt @@ -2,6 +2,10 @@ package net.averak.gsync.testkit import com.google.common.base.CaseFormat import groovy.sql.Sql +import java.sql.Date +import java.sql.Timestamp +import java.time.LocalDate +import java.time.LocalDateTime class Fixture { @@ -42,7 +46,7 @@ class Fixture { private fun extractTableName(entity: Any): String { val tableName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, entity.javaClass.simpleName).replace("_entity", "") - return "`$tableName`" + return "`gsync_$tableName`" } private fun extractColumns(entity: Any): Map { @@ -50,7 +54,20 @@ class Fixture { entity.javaClass.declaredFields.forEach { val columnName = CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, it.name) it.isAccessible = true - result["`$columnName`"] = it[entity] + + when (val value = it[entity]) { + is LocalDateTime -> { + result["`$columnName`"] = Timestamp.valueOf(value) + } + + is LocalDate -> { + result["`$columnName`"] = Date.valueOf(value) + } + + else -> { + result["`$columnName`"] = value + } + } it.isAccessible = false } return result From 7e2e65f1fe26039265b705858b80b51f001a73ee Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Tue, 9 Jan 2024 10:24:24 +0900 Subject: [PATCH 25/26] =?UTF-8?q?refs=20#37=20Redis=E3=82=AF=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=82=A2=E3=83=B3=E3=83=88=E3=82=92=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../averak/gsync/core/daterange/Dateline.kt | 2 - .../gsync/infrastructure/redis/RedisClient.kt | 202 +++++++++++++++++ .../redis/RedisClient_UT.groovy | 204 ++++++++++++++++++ build.gradle.kts | 1 + .../gsync/testkit/AbstractDatabaseSpec.groovy | 6 + 5 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/redis/RedisClient.kt create mode 100644 app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/redis/RedisClient_UT.groovy diff --git a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt index 759d1b4..320af64 100644 --- a/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt @@ -30,8 +30,6 @@ data class Dateline( companion object { // 06:00 (JST) に日付を切り替えるので、06:00:00 - 09:00:00 = -03:00:00 (UTC) になる - // 22:00 (JST) に注文を締め切ることから、デフォルトの日付変更境界線は -11:00:00 (UTC) とする - // JST だと -02:00:00 なので、UTC だと -02:00:00 - 09:00:00 = -11:00:00 になる @JvmStatic val DEFAULT = Dateline(true, 3, 0, 0) } diff --git a/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/redis/RedisClient.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/redis/RedisClient.kt new file mode 100644 index 0000000..3062dac --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/redis/RedisClient.kt @@ -0,0 +1,202 @@ +package net.averak.gsync.infrastructure.redis + +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration + +/** + * Redis クライアント + */ +@Component +@SuppressWarnings("kotlin:S6518") +class RedisClient( + private val client: StringRedisTemplate, +) { + + /** + * キーの値を取得する + * + * @return キーが存在しない、もしくは pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: GET + */ + fun get(key: String): String? { + try { + return client.opsForValue().get(key) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの存在を確認する + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: EXISTS + */ + fun exists(key: String): Boolean? { + try { + return client.execute { + it.commands().exists(key.toByteArray()) + } + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの有効期限を取得する + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: TTL + */ + fun ttl(key: String): Long? { + try { + return client.execute { + it.commands().ttl(key.toByteArray()) + } + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの値をセットする (UPSERT) + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: SET + */ + fun set(key: String, value: String): Boolean? { + try { + return client.execute { + it.commands().set(key.toByteArray(), value.toByteArray()) + } + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーが存在しない場合に、キーの値をセットする (INSERT) + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: SET + */ + fun setnx(key: String, value: String): Boolean? { + try { + return client.opsForValue().setIfAbsent(key, value) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの値をセットする (UPSERT) と同時に、キーの有効期限を設定する + * + * @see Redis Documentation: SETEX + */ + fun setex(key: String, value: String, seconds: Long) { + try { + client.opsForValue().set(key, value, Duration.ofSeconds(seconds)) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの値を削除する + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: DEL + */ + fun del(keys: List): Long? { + if (keys.isEmpty()) { + return 0 + } + + try { + return client.execute { + it.commands().del(*keys.map { key -> key.toByteArray() }.toTypedArray()) + } + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの有効期限を設定する + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: EXPIRE + */ + fun expire(key: String, seconds: Long): Boolean? { + try { + return client.expire(key, Duration.ofSeconds(seconds)) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの値をインクリメントする + * キーが存在しない場合、0で初期化してからインクリメントされる (1になる) + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: INCR + */ + fun incr(key: String): Long? { + try { + return client.opsForValue().increment(key) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * キーの値をデクリメントする + * キーが存在しない場合、0で初期化してからデクリメントされる (-1になる) + * + * @return pipeline / transaction 内で実行された場合は NULL になる + * @see Redis Documentation: DECR + */ + fun decr(key: String): Long? { + try { + return client.opsForValue().decrement(key) + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * データベース内の全てのキーを削除する + * + * @see Redis Documentation: FLUSHDB + */ + fun flushdb() { + try { + client.execute { connection -> + connection.serverCommands().flushDb() + } + } catch (e: Exception) { + throw RedisException(e) + } + } + + /** + * トランザクション内で処理を実行する + * + * @return アトミックなトランザクション内の各コマンドに対応する応答 + * @see Redis Documentation: Transactions + */ + fun transaction(actions: () -> Unit): List { + try { + client.multi() + client.setEnableTransactionSupport(true) + actions() + return client.exec() + } catch (e: Exception) { + throw RedisException(e) + } + } +} + +class RedisException(causedBy: Throwable) : Exception(causedBy) diff --git a/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/redis/RedisClient_UT.groovy b/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/redis/RedisClient_UT.groovy new file mode 100644 index 0000000..523288f --- /dev/null +++ b/app/infrastructure/src/test/groovy/net/averak/gsync/infrastructure/redis/RedisClient_UT.groovy @@ -0,0 +1,204 @@ +package net.averak.gsync.infrastructure.redis + +import net.averak.gsync.testkit.AbstractDatabaseSpec +import org.springframework.beans.factory.annotation.Autowired + +class RedisClient_UT extends AbstractDatabaseSpec { + + @Autowired + RedisClient sut + + def "get: キーの値を取得する"() { + given: + this.sut.set("k1", "v1") + + when: + final result = this.sut.get(key) + + then: + result == expectedResult + + where: + key || expectedResult + "k1" || "v1" + "k2" || null + } + + def "exists: キーの存在を確認する"() { + given: + this.redis.set("k1", "v1") + + when: + final result = this.sut.exists(key) + + then: + result == expectedResult + + where: + key || expectedResult + "k1" || true + "k2" || false + } + + def "ttl: キーの有効期限を取得する"() { + given: + this.redis.setex("k1", "v1", 10) + + when: + final result = this.sut.ttl(key) + + then: + result == expectedResult + + where: + key || expectedResult + "k1" || 10 + "k2" || -2 + } + + def "set: キーの値を設定する"() { + given: + this.redis.set("k1", "v1") + + when: + this.sut.set(key, value) + + then: + this.redis.get(key) == expectedValue + + where: + key | value || expectedResult | expectedValue + "k1" | "updated" || true | "updated" + "k2" | "created" || true | "created" + } + + def "setnx: キーが存在しない場合に、キーの値をセットする"() { + given: + this.redis.set("k1", "v1") + + when: + final result = this.sut.setnx(key, value) + + then: + result == expectedResult + this.redis.get(key) == expectedValue + + where: + key | value || expectedResult || expectedValue + "k1" | "updated" || false || "v1" + "k2" | "created" || true || "created" + } + + def "setex: キーの値をセットすると同時に、キーの有効期限を設定する"() { + given: + this.redis.set("k1", "v1") + + when: + this.sut.setex(key, value, seconds) + + then: + this.redis.get(key) == expectedValue + this.redis.ttl(key) == expectedTtl + + where: + key | value | seconds || expectedValue || expectedTtl + "k1" | "updated" | 10 || "updated" || 10 + "k2" | "created" | 20 || "created" || 20 + } + + def "del: キーを削除する"() { + given: + this.redis.set("k1", "v1") + this.redis.set("k2", "v2") + + when: + final result = this.sut.del(keys) + + then: + result == expectedResult + + where: + keys || expectedResult + [] || 0 + ["k1"] || 1 + ["k1", "k2"] || 2 + ["k1", "k2", "k3"] || 2 + } + + def "expire: キーの有効期限を設定する"() { + given: + this.redis.set("k1", "v1") + + when: + final result = this.sut.expire(key, seconds) + + then: + result == expectedResult + this.redis.ttl(key) == expectedTtl + + where: + key | seconds || expectedResult || expectedTtl + "k1" | 10 || true || 10 + "k2" | 20 || false || -2 + } + + def "incr: キーの値をインクリメントする"() { + given: + this.redis.set("k1", "1") + + when: + final result = this.sut.incr(key) + + then: + result == expectedResult + this.redis.get(key) == expectedValue + + where: + key || expectedResult || expectedValue + "k1" || 2 || "2" + "k2" || 1 || "1" + } + + def "decr: キーの値をデクリメントする"() { + given: + this.redis.set("k1", "2") + + when: + final result = this.sut.decr(key) + + then: + result == expectedResult + this.redis.get(key) == expectedValue + + where: + key || expectedResult || expectedValue + "k1" || 1 || "1" + "k2" || -1 || "-1" + } + + def "flushdb: データベース内の全てのキーを削除する"() { + given: + this.redis.set("k1", "v1") + this.redis.set("k2", "v2") + + when: + this.sut.flushdb() + + then: + this.redis.get("k1") == null + this.redis.get("k2") == null + } + + def "transaction: トランザクション内で処理を実行する"() { + when: + final result = this.sut.transaction { + this.sut.set("k1", "1") + this.sut.incr("k1") + this.sut.decr("k1") + } + + then: + result == [true, 2, 1] + this.redis.get("k1") == "1" + } +} diff --git a/build.gradle.kts b/build.gradle.kts index e027cdd..7e5a5c3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -164,6 +164,7 @@ project(":infrastructure") { dependencies { implementation(rootProject.libs.spring.boot.starter.web) implementation(rootProject.libs.spring.boot.starter.webflux) + implementation(rootProject.libs.spring.boot.starter.data.redis) implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.jackson.module.kotlin) implementation(rootProject.libs.jackson.datatype.jsr310) diff --git a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy index 7f29062..28f520e 100644 --- a/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy @@ -2,6 +2,7 @@ package net.averak.gsync.testkit import groovy.sql.Sql import jakarta.annotation.PostConstruct +import net.averak.gsync.infrastructure.redis.RedisClient import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.autoconfigure.EnableAutoConfiguration import org.springframework.transaction.annotation.Transactional @@ -15,12 +16,17 @@ abstract class AbstractDatabaseSpec extends AbstractSpec { @Autowired Sql sql + @Autowired + RedisClient redis + @PostConstruct private void init() { Fixture.init(sql) } void cleanup() { + redis.flushdb() + // なぜか @Transactional でロールバックされないので、仕方なく DELETE クエリを実行している sql.execute("DELETE FROM gsync_echo WHERE echo_id IS NOT NULL") } From 95dbe8daff7803b60cfcf8da910215ba45c19cf6 Mon Sep 17 00:00:00 2001 From: tatsuya-abe Date: Tue, 9 Jan 2024 10:28:55 +0900 Subject: [PATCH 26/26] =?UTF-8?q?refs=20#37=20=E4=B8=8D=E8=A6=81=E3=81=AA?= =?UTF-8?q?=E4=BE=9D=E5=AD=98=E9=96=A2=E4=BF=82=E3=82=92=E3=82=AF=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=83=B3=E3=82=A2=E3=83=83=E3=83=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 6 +----- gradle/libs.versions.toml | 20 +++++++++----------- src/main/resources/application.yaml | 11 +---------- 3 files changed, 11 insertions(+), 26 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 7e5a5c3..b036b4c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,7 @@ import java.io.ByteArrayOutputStream plugins { - kotlin("jvm") version libs.versions.kotlin + kotlin("jvm") version "1.9.20" alias(libs.plugins.versions) alias(libs.plugins.version.catalog.update) @@ -138,8 +138,6 @@ project(":adapter") { implementation(rootProject.libs.spring.boot.starter.web) implementation(rootProject.libs.spring.boot.starter.webflux) implementation(rootProject.libs.spring.boot.starter.data.jpa) - implementation(rootProject.libs.google.cloud.spanner.spring) - implementation(rootProject.libs.google.cloud.spanner.hibernate) implementation(rootProject.libs.mybatis.spring.boot.starter) testImplementation(rootProject.libs.spring.boot.starter.test) @@ -165,7 +163,6 @@ project(":infrastructure") { implementation(rootProject.libs.spring.boot.starter.web) implementation(rootProject.libs.spring.boot.starter.webflux) implementation(rootProject.libs.spring.boot.starter.data.redis) - implementation(rootProject.libs.google.cloud.spanner.spring) implementation(rootProject.libs.jackson.module.kotlin) implementation(rootProject.libs.jackson.datatype.jsr310) implementation(rootProject.libs.mybatis.spring.boot.starter) @@ -177,7 +174,6 @@ project(":usecase") { dependencies { implementation(project(":core")) implementation(project(":domain")) - implementation(rootProject.libs.google.cloud.spanner.spring) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f661e5..0de3503 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,22 +2,23 @@ easy-random = "6.2.1" flyway = "10.4.1" groovy = "4.0.7" -kotlin = "1.9.20" mybatis-generator = "1.4.2" spock = "2.4-M1-groovy-4.0" spring-boot = "3.2.1" [libraries] -commons-lang3 = "org.apache.commons:commons-lang3:3.13.0" +commons-lang3 = "org.apache.commons:commons-lang3:3.14.0" easy-random = { module = "io.github.dvgaba:easy-random-core", version.ref = "easy-random" } flyway-core = { module = "org.flywaydb:flyway-core", version.ref = "flyway" } -flyway-spanner = { module = "org.flywaydb:flyway-gcp-spanner", version.ref = "flyway" } flyway-gradle-plugin = { module = "org.flywaydb:flyway-gradle-plugin", version.ref = "flyway" } +flyway-spanner = { module = "org.flywaydb:flyway-gcp-spanner", version.ref = "flyway" } +google-cloud-spanner-jdbc = "com.google.cloud:google-cloud-spanner-jdbc:2.15.0" +google-cloud-spanner-spring = "com.google.cloud:spring-cloud-gcp-starter-data-spanner:5.0.0" groovy = { module = "org.apache.groovy:groovy", version.ref = "groovy" } groovy-sql = { module = "org.apache.groovy:groovy-sql", version.ref = "groovy" } -guava = "com.google.guava:guava:32.1.3-jre" -jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1" +guava = "com.google.guava:guava:33.0.0-jre" jackson-datatype-jsr310 = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.16.1" +jackson-module-kotlin = "com.fasterxml.jackson.module:jackson-module-kotlin:2.16.1" logback-classic = "ch.qos.logback:logback-classic:1.4.14" logstash-logback-encoder = "net.logstash.logback:logstash-logback-encoder:7.4" mybatis-generator-core = { module = "org.mybatis.generator:mybatis-generator-core", version.ref = "mybatis-generator" } @@ -32,14 +33,11 @@ spring-boot-starter-data-redis = { module = "org.springframework.boot:spring-boo spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } spring-boot-starter-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot" } -google-cloud-spanner-spring = "com.google.cloud:spring-cloud-gcp-starter-data-spanner:5.0.0" -google-cloud-spanner-jdbc = "com.google.cloud:google-cloud-spanner-jdbc:2.15.0" -google-cloud-spanner-hibernate = "com.google.cloud:google-cloud-spanner-hibernate-dialect:3.0.3" [plugins] flyway = { id = "org.flywaydb.flyway", version.ref = "flyway" } gradle-git-properties = "com.gorylenko.gradle-git-properties:2.4.1" sonarqube = "org.sonarqube:4.4.1.3373" -spotless = "com.diffplug.spotless:6.22.0" -version-catalog-update = "nl.littlerobots.version-catalog-update:0.8.1" -versions = "com.github.ben-manes.versions:0.49.0" +spotless = "com.diffplug.spotless:6.23.3" +version-catalog-update = "nl.littlerobots.version-catalog-update:0.8.3" +versions = "com.github.ben-manes.versions:0.50.0" diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index ba89e71..9c89a91 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -9,20 +9,11 @@ spring: instance-id: ${GCP_SPANNER_INSTANCE_ID:sandbox} database: ${GCP_SPANNER_DATABASE:sandbox} emulator: - enabled: true + enabled: ${GCP_SPANNER_EMULATOR_ENABLED:true} emulator-host: ${SPANNER_EMULATOR_HOST:localhost:9010} datasource: url: jdbc:cloudspanner://${spring.cloud.gcp.spanner.emulator-host}/projects/${spring.cloud.gcp.spanner.project-id}/instances/${spring.cloud.gcp.spanner.instance-id}/databases/${spring.cloud.gcp.spanner.database}?autoConfigEmulator=${spring.cloud.gcp.spanner.emulator.enabled} driver-class-name: com.google.cloud.spanner.jdbc.JdbcDriver - jpa: - hibernate: - ddl-auto: update - database-platform: com.google.cloud.spanner.hibernate.SpannerDialect - properties: - hibernate: - jdbc: - batch_size: 100 - order_updates: true data: redis: host: ${REDIS_HOST:localhost}