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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bb22df --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: CI + +on: + push: + branches: + - master + - develop + pull_request: + branches: + - master + - develop + +jobs: + test: + runs-on: ubuntu-latest + + 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: cache sonar packages + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: launch docker + run: | + docker-compose up -d + + - name: backend test + run: | + ./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 + + 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 + + - name: build docker image + uses: docker/build-push-action@v3 + with: + context: . + push: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1eff0e --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# 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 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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..74a356e --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +.PHONY: build +build: + ./gradlew build -x test + +.PHONY: test +test: + ./gradlew test jacocoTestReport + +.PHONY: lint +lint: + ./gradlew spotlessCheck + +.PHONY: format +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 + +.PHONY: update_dependencies +update_dependencies: + ./gradlew versionCatalogUpdate diff --git a/README.md b/README.md new file mode 100644 index 0000000..04c4942 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# gsync + +![CI](https://github.com/averak/gsync/workflows/CI/badge.svg) + +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 + +This document only contains minimal setup instructions to launch the application. + +For more information, see [Makefile](./Makefile). + +### Environments + +* Java OpenJDK 17 +* Kotlin 1.9 +* Spring Boot 3.2 +* Cloud Spanner +* Redis + +### Running the application in dev mode + +You can run your application in dev mode. + +```shell +docker compose up -d +./gradlew bootRun +``` + +### Packaging and running the application + +The application can be packaged. + +```shell +make build +``` + +It produces the `gsync.jar` file in the `build/libs/` directory. + +The application is now runnable using `java -jar build/libs/gsync.jar`. + +### Check Dependency updates + +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 +./gradlew versionCatalogUpdate --interactive + +# 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/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..99c8e19 --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoEntity.java @@ -0,0 +1,207 @@ +package net.averak.gsync.adapter.dao.entity.base; + +import java.time.LocalDateTime; +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 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 + * the database table gsync_echo + * + * @mbg.generated + */ + 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; + } + + /** + * 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 LocalDateTime 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 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 new file mode 100644 index 0000000..2b11e54 --- /dev/null +++ b/app/adapter/src/main/java/net/averak/gsync/adapter/dao/entity/base/EchoExample.java @@ -0,0 +1,622 @@ +package net.averak.gsync.adapter.dao.entity.base; + +import java.time.LocalDateTime; +import java.util.ArrayList; +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(LocalDateTime value) { + addCriterion("`timestamp` =", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampNotEqualTo(LocalDateTime value) { + addCriterion("`timestamp` <>", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampGreaterThan(LocalDateTime value) { + addCriterion("`timestamp` >", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampGreaterThanOrEqualTo(LocalDateTime value) { + addCriterion("`timestamp` >=", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampLessThan(LocalDateTime value) { + addCriterion("`timestamp` <", value, "timestamp"); + return (Criteria) this; + } + + public Criteria andTimestampLessThanOrEqualTo(LocalDateTime 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(LocalDateTime value1, LocalDateTime value2) { + addCriterion("`timestamp` between", value1, value2, "timestamp"); + return (Criteria) this; + } + + 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; + } + } + + /** + * 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/handler/rest/GlobalRestControllerAdvice.kt b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt new file mode 100644 index 0000000..75dbe79 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice.kt @@ -0,0 +1,72 @@ +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) + } + + val body = ErrorResponse(e) + when (e.errorCode) { + ErrorCode.NOT_FOUND_API -> { + this.customLogger.warn(requestScope.getGameContext(), e) + return ResponseEntity(body, HttpStatus.NOT_FOUND) + } + + else -> { + this.customLogger.error(requestScope.getGameContext(), e) + 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/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..4bbce14 --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/handler/rest/HttpRequestScope.kt @@ -0,0 +1,71 @@ +package net.averak.gsync.adapter.handler.rest + +import jakarta.servlet.http.HttpServletRequest +import net.averak.gsync.core.config.Config +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( + 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(): UUID? { + return httpServletRequest.getHeader(HeaderName.IDEMPOTENCY_KEY.key)?.let { + UUID.fromString(it) + } + } + + 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..daf411b --- /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.toString(), + "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..fe87332 --- /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.game_context.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(), + 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/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..01c40df --- /dev/null +++ b/app/adapter/src/main/kotlin/net/averak/gsync/adapter/repository/EchoRepository.kt @@ -0,0 +1,45 @@ +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.util.* + +@Repository +open class EchoRepository( + private val echoMapper: EchoMapper, +) : IEchoRepository { + + 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/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/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..561af70 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/AbstractController_IT.groovy @@ -0,0 +1,160 @@ +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 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 + } + + /** + * 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..59c6177 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/GlobalRestControllerAdvice_IT.groovy @@ -0,0 +1,17 @@ +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 { + + def "異常系 存在しないパスの場合はエラーを返す"() { + given: + final path = "/api/xxx" + + expect: + final request = this.getRequest(path) + 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 new file mode 100644 index 0000000..b33e064 --- /dev/null +++ b/app/adapter/src/test/groovy/net/averak/gsync/adapter/handler/rest/HealthCheckController_IT.groovy @@ -0,0 +1,26 @@ +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 + static final String BASE_PATH = "/api/health" + static final String HEALTH_CHECK_PATH = BASE_PATH + + def "ヘルスチェックAPI: 正常系 200 OKを返す"() { + 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/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/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..07691f7 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/DateRange.kt @@ -0,0 +1,30 @@ +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(time, Dateline.DEFAULT) + + constructor(time: LocalDateTime, dateline: Dateline) : this(dateline.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..320af64 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/daterange/Dateline.kt @@ -0,0 +1,76 @@ +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 { + + // 06:00 (JST) に日付を切り替えるので、06:00:00 - 09:00:00 = -03:00:00 (UTC) になる + @JvmStatic + val DEFAULT = Dateline(true, 3, 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/game_context/GameContext.kt b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt new file mode 100644 index 0000000..d766be4 --- /dev/null +++ b/app/core/src/main/kotlin/net/averak/gsync/core/game_context/GameContext.kt @@ -0,0 +1,21 @@ +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: UUID, + 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..02c8d3f --- /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.game_context.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.toString(), + "currentTime" to gctx.currentTime.toString(), + ), + ) + } + + private fun makeServerInfoPayload(gctx: GameContext): StructuredArgument { + return StructuredArguments.value( + "server", + mapOf( + "version" to gctx.serverVersion, + ), + ) + } +} 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/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt b/app/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt new file mode 100644 index 0000000..942d587 --- /dev/null +++ b/app/domain/src/main/kotlin/net/averak/gsync/domain/model/Echo.kt @@ -0,0 +1,17 @@ +package net.averak.gsync.domain.model + +import java.time.LocalDateTime +import java.util.* + +data class Echo( + val id: UUID, + val message: String, + val timestamp: LocalDateTime, +) { + + constructor(message: String, now: LocalDateTime) : this( + UUID.randomUUID(), + message, + now, + ) +} 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..464e4a9 --- /dev/null +++ b/app/domain/src/main/kotlin/net/averak/gsync/domain/repository/IEchoRepository.kt @@ -0,0 +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.* + +interface IEchoRepository { + + 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/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/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt new file mode 100644 index 0000000..4085146 --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/IgnoreTablePlugin.kt @@ -0,0 +1,45 @@ +package net.averak.gsync.infrastructure.mybatis.plugin + +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/plugin/RenameGeneratedFilesPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/RenameGeneratedFilesPlugin.kt new file mode 100644 index 0000000..9fd294a --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/RenameGeneratedFilesPlugin.kt @@ -0,0 +1,31 @@ +package net.averak.gsync.infrastructure.mybatis.plugin + +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/plugin/ResolveNullPlugin.kt b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/ResolveNullPlugin.kt new file mode 100644 index 0000000..b9b5681 --- /dev/null +++ b/app/infrastructure/src/main/kotlin/net/averak/gsync/infrastructure/mybatis/plugin/ResolveNullPlugin.kt @@ -0,0 +1,57 @@ +package net.averak.gsync.infrastructure.mybatis.plugin + +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/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/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/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/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/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..e4f4680 --- /dev/null +++ b/app/usecase/src/main/kotlin/net/averak/gsync/usecase/EchoUsecase.kt @@ -0,0 +1,22 @@ +package net.averak.gsync.usecase + +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 +import org.springframework.stereotype.Service + +@Service +class EchoUsecase( + private val transaction: ITransaction, + private val echoRepository: IEchoRepository, +) { + + fun echo(gctx: GameContext, message: String): Echo { + return transaction.beginRwTransaction { + val echo = Echo(message, gctx.currentTime) + echoRepository.save(gctx, 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/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 new file mode 100644 index 0000000..efdac6a --- /dev/null +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/AbstractUsecase_UT.groovy @@ -0,0 +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 new file mode 100644 index 0000000..2379021 --- /dev/null +++ b/app/usecase/src/test/groovy/net/averak/gsync/usecase/EchoUsecase_UT.groovy @@ -0,0 +1,25 @@ +package net.averak.gsync.usecase + +import net.averak.gsync.core.game_context.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: + 1 * this.echoRepository.save(gctx, _) + result.timestamp == gctx.currentTime + result.message == message + } +} diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..b036b4c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,263 @@ +import java.io.ByteArrayOutputStream + +plugins { + kotlin("jvm") version "1.9.20" + + 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) + + groovy + jacoco +} + +buildscript { + dependencies { + classpath(libs.spring.boot.gradle.plugin) + classpath(libs.flyway.gradle.plugin) + classpath(libs.flyway.spanner) + classpath(libs.google.cloud.spanner.jdbc) + } +} + +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" + } + } + } + } + + 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", + ), + ) + } + + java { + targetExclude("build/**") + eclipse() + } + + 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/**,**/dto/*,**/entity/base/*,**/mapper/base/*") + } + } + + tasks { + test { + useJUnitPlatform() + } + + processResources { + dependsOn(":generateGitProperties") + } + + processTestResources { + dependsOn(":generateGitProperties") + } + + jacocoTestReport { + reports { + xml.required = true + csv.required = true + } + } + } +} + +subprojects { + sourceSets { + test { + resources.srcDir("$rootDir/src/test/resources") + } + } + + dependencies { + implementation(rootProject.libs.spring.boot.starter) + implementation(rootProject.libs.guava) + testImplementation(project(":testkit")) + } +} + +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) + implementation(rootProject.libs.spring.boot.starter.data.jpa) + implementation(rootProject.libs.mybatis.spring.boot.starter) + + testImplementation(rootProject.libs.spring.boot.starter.test) + } +} + +project(":core") { + dependencies { + implementation(rootProject.libs.logback.classic) + implementation(rootProject.libs.logstash.logback.encoder) + } +} + +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.spring.boot.starter.data.redis) + 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) + } +} + +project(":usecase") { + dependencies { + implementation(project(":core")) + implementation(project(":domain")) + } +} + +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) + implementation(rootProject.libs.flyway.core) + + api(rootProject.libs.spock.core) + api(rootProject.libs.spock.spring) + api(rootProject.libs.groovy.sql) + api(rootProject.libs.easy.random) + } + + 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.spring.boot.starter.data.jpa) + implementation(libs.google.cloud.spanner.jdbc) + implementation(libs.flyway.core) + 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 { + 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") +} + +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/compose.yaml b/compose.yaml new file mode 100644 index 0000000..eb97555 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,27 @@ +services: + 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 + platform: linux/x86_64 + 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-sandbox" + SPANNER_EMULATOR_URL: "http://spanner-emulator:9020/" + INSTANCE_NAME: "sandbox" + DATABASE_NAME: "sandbox" + + redis: + image: redis:7.0 + ports: + - "6379:6379" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..0de3503 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,43 @@ +[versions] +easy-random = "6.2.1" +flyway = "10.4.1" +groovy = "4.0.7" +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.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-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: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" } +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" } +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" } + +[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.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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..62d4c05 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e411586 --- /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.4-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..9a30d66 --- /dev/null +++ b/settings.gradle @@ -0,0 +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/kotlin/net/averak/gsync/Entrypoint.kt b/src/main/kotlin/net/averak/gsync/Entrypoint.kt new file mode 100644 index 0000000..3c6e748 --- /dev/null +++ b/src/main/kotlin/net/averak/gsync/Entrypoint.kt @@ -0,0 +1,28 @@ +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/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..9c89a91 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,54 @@ +spring: + config: + import: + - classpath:/git.properties + cloud: + gcp: + spanner: + project-id: ${GCP_PROJECT_ID:gsync-sandbox} + instance-id: ${GCP_SPANNER_INSTANCE_ID:sandbox} + database: ${GCP_SPANNER_DATABASE:sandbox} + emulator: + 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 + 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 + 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:/db/migration + connect-retries: 5 + +server: + port: ${PORT:8080} + servlet: + encoding: + charset: UTF-8 + force: true + +mybatis: + configuration: + map-underscore-to-camel-case: true + 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} + 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..dde3db9 --- /dev/null +++ b/src/main/resources/dao/base/EchoBaseMapper.xml @@ -0,0 +1,291 @@ + + + + + + + + + + + + + + + + + + + + + + + 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`, `created_at`, `updated_at` + + + + + + delete from `gsync_echo` + where `echo_id` = #{echoId,jdbcType=NVARCHAR} + + + + delete from `gsync_echo` + + + + + + + insert into `gsync_echo` (`echo_id`, `message`, `timestamp`, + `created_at`, `updated_at`) + values (#{echoId,jdbcType=NVARCHAR}, #{message,jdbcType=NVARCHAR}, #{timestamp,jdbcType=TIMESTAMP}, + #{createdAt,jdbcType=TIMESTAMP}, #{updatedAt,jdbcType=TIMESTAMP}) + + + + insert into `gsync_echo` + + + `echo_id`, + + + `message`, + + + `timestamp`, + + + `created_at`, + + + `updated_at`, + + + + + #{echoId,jdbcType=NVARCHAR}, + + + #{message,jdbcType=NVARCHAR}, + + + #{timestamp,jdbcType=TIMESTAMP}, + + + #{createdAt,jdbcType=TIMESTAMP}, + + + #{updatedAt,jdbcType=TIMESTAMP}, + + + + + + + update `gsync_echo` + + + `echo_id` = #{row.echoId,jdbcType=NVARCHAR}, + + + `message` = #{row.message,jdbcType=NVARCHAR}, + + + `timestamp` = #{row.timestamp,jdbcType=TIMESTAMP}, + + + `created_at` = #{row.createdAt,jdbcType=TIMESTAMP}, + + + `updated_at` = #{row.updatedAt,jdbcType=TIMESTAMP}, + + + + + + + + + update `gsync_echo` + set `echo_id` = #{row.echoId,jdbcType=NVARCHAR}, + `message` = #{row.message,jdbcType=NVARCHAR}, + `timestamp` = #{row.timestamp,jdbcType=TIMESTAMP}, + `created_at` = #{row.createdAt,jdbcType=TIMESTAMP}, + `updated_at` = #{row.updatedAt,jdbcType=TIMESTAMP} + + + + + + + update `gsync_echo` + + + `message` = #{message,jdbcType=NVARCHAR}, + + + `timestamp` = #{timestamp,jdbcType=TIMESTAMP}, + + + `created_at` = #{createdAt,jdbcType=TIMESTAMP}, + + + `updated_at` = #{updatedAt,jdbcType=TIMESTAMP}, + + + where `echo_id` = #{echoId,jdbcType=NVARCHAR} + + + + update `gsync_echo` + set `message` = #{message,jdbcType=NVARCHAR}, + `timestamp` = #{timestamp,jdbcType=TIMESTAMP}, + `created_at` = #{createdAt,jdbcType=TIMESTAMP}, + `updated_at` = #{updatedAt,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..e3e7842 --- /dev/null +++ b/src/main/resources/db/migration/V1_0_0_0__init_schema.sql @@ -0,0 +1,9 @@ +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/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/main/resources/mybatis-generator-config.xml b/src/main/resources/mybatis-generator-config.xml new file mode 100644 index 0000000..a7b6271 --- /dev/null +++ b/src/main/resources/mybatis-generator-config.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml new file mode 100644 index 0000000..d436fcf --- /dev/null +++ b/src/test/resources/application-test.yaml @@ -0,0 +1,9 @@ +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 new file mode 100644 index 0000000..f1eafcf --- /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/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..28f520e --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/AbstractDatabaseSpec.groovy @@ -0,0 +1,33 @@ +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 + +// @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 + + @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") + } +} 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..04fc8e8 --- /dev/null +++ b/testkit/src/main/groovy/net/averak/gsync/testkit/Assert.groovy @@ -0,0 +1,32 @@ +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 + +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 + } + + /** + * タイムスタンプが一致するか検証 + * + * @param approxDuration 許容する誤差 + */ + 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/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 new file mode 100644 index 0000000..cf3d4f5 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Faker.kt @@ -0,0 +1,164 @@ +package net.averak.gsync.testkit + +import org.apache.commons.lang3.RandomStringUtils +import org.jeasy.random.EasyRandom +import org.jeasy.random.EasyRandomParameters +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(): UUID { + return UUID.randomUUID() + } + + /** + * UUIDv5を生成する + */ + @JvmStatic + 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 new file mode 100644 index 0000000..293644d --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/Fixture.kt @@ -0,0 +1,76 @@ +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 { + + 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 "`gsync_$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 + + 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 + } + } +} 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 +} 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..7a11ff1 --- /dev/null +++ b/testkit/src/main/kotlin/net/averak/gsync/testkit/TestConfig.kt @@ -0,0 +1,60 @@ +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 +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>, +) { + + 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( + DataSourceBuilder.create() + .driverClassName(properties.driverClassName) + .url(properties.url) + .build(), + ) + } + + @Bean + open fun sql(dataSource: DataSource): Sql { + return Sql(dataSource) + } +}