diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..65326bb6 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use nix \ No newline at end of file diff --git a/.github/workflows/javadocs.yml b/.github/workflows/javadocs.yml index f1cd1f5c..8099f172 100644 --- a/.github/workflows/javadocs.yml +++ b/.github/workflows/javadocs.yml @@ -10,12 +10,12 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 with: - distribution: adopt - java-version: 17 + distribution: temurin + java-version: 25 - name: Build Javadocs run: | echo "Building javadocs with gradle" diff --git a/.github/workflows/publish_to_nexus.yml b/.github/workflows/publish_to_nexus.yml index 2d991358..aaa07619 100644 --- a/.github/workflows/publish_to_nexus.yml +++ b/.github/workflows/publish_to_nexus.yml @@ -13,12 +13,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: 25 - name: Build with Gradle run: ./gradlew --build-cache build - name: Test with Gradle diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 2233c1b9..ef8bc88a 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -7,12 +7,12 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up JDK 17 - uses: actions/setup-java@v4 + - uses: actions/checkout@v6 + - name: Set up JDK 25 + uses: actions/setup-java@v5 with: distribution: temurin - java-version: 17 + java-version: 25 - name: Build with Gradle run: ./gradlew --build-cache build - name: Test with Gradle diff --git a/.gitignore b/.gitignore index b15608ba..5ab09fc8 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ buildNumber.properties /config/ /logs/ /out/ +.direnv \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 23491868..12dab94d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,7 +9,7 @@ plugins { } group = "de.chojo.universalis" -version = "1.4.2" +version = "2.0.0" repositories { maven("https://eldonexus.de/repository/maven-public") @@ -50,13 +50,14 @@ allprojects { withSourcesJar() withJavadocJar() toolchain { - languageVersion.set(JavaLanguageVersion.of(17)) + languageVersion.set(JavaLanguageVersion.of(25)) } } dependencies { val testlibs = rootProject.testlibs testImplementation(testlibs.bundles.junit) + testRuntimeOnly(testlibs.junit.platform.launcher) testImplementation(testlibs.mockito.core) testImplementation(testlibs.slf4j.noop) } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e6441136..b1b8ef56 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index b82aa23a..a9db1155 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 1aa94a42..b9bb139f 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,7 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -112,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -170,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -203,15 +203,14 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 7101f8e4..aa5f10b0 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -13,6 +13,8 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @@ -21,8 +23,8 @@ @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -49,7 +51,7 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -63,30 +65,18 @@ echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 -goto fail +"%COMSPEC%" /c exit 1 :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 %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 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! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel -:omega +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts index adfb7deb..31956545 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,47 +20,49 @@ pluginManagement { dependencyResolutionManagement { versionCatalogs { create("libs") { - plugin("spotless", "com.diffplug.spotless").version("6.25.0") + plugin("spotless", "com.diffplug.spotless").version("8.4.0") - version("jackson", "2.17.0") - library("jackson-core", "com.fasterxml.jackson.core", "jackson-core").versionRef("jackson") - library("jackson-databind", "com.fasterxml.jackson.core", "jackson-databind").versionRef("jackson") - library("jackson-annotations", "com.fasterxml.jackson.core", "jackson-annotations").versionRef("jackson") - library("jackson-yaml", "com.fasterxml.jackson.dataformat", "jackson-dataformat-yaml").versionRef("jackson") + version("jackson", "3.2.0") + library("jackson-core", "tools.jackson.core", "jackson-core").versionRef("jackson") + library("jackson-databind", "tools.jackson.core", "jackson-databind").versionRef("jackson") + library("jackson-annotations", "com.fasterxml.jackson.core", "jackson-annotations").version("2.22") + library("jackson-yaml", "tools.jackson.dataformat", "jackson-dataformat-yaml").versionRef("jackson") bundle("jackson-full", listOf("jackson-core", "jackson-databind", "jackson-annotations", "jackson-yaml")) bundle("jackson-lite", listOf("jackson-core", "jackson-databind", "jackson-annotations")) - library("jetbrains-annotations", "org.jetbrains:annotations:24.1.0") + library("jetbrains-annotations", "org.jetbrains:annotations:26.1.0") - version("slf4j", "2.0.13") + version("slf4j", "2.0.18") library("slf4j-api", "org.slf4j", "slf4j-api").versionRef("slf4j") - version("log4j", "2.23.1") - // "org.apache.logging.log4j", "log4j-core", "2.23.1" + version("log4j", "2.26.0") + // "org.apache.logging.log4j", "log4j-core", "2.26.0" library("apache-log4j-core", "org.apache.logging.log4j", "log4j-core").versionRef("log4j") library("apache-log4j-slf4j", "org.apache.logging.log4j", "log4j-slf4j2-impl").versionRef("log4j") bundle("log4j", listOf("apache-log4j-core", "apache-log4j-slf4j")) library("bucket4j", "com.bucket4j:bucket4j-core:8.10.1") - library("apache-httpclient", "org.apache.httpcomponents.client5:httpclient5:5.3.1") + library("apache-httpclient", "org.apache.httpcomponents.client5:httpclient5:5.6.2") library("websocketclient", "com.neovisionaries:nv-websocket-client:2.14") - library("mongodb-bson", "org.mongodb:bson:5.0.1") - library("google-guava", "com.google.guava:guava:33.1.0-jre") + library("mongodb-bson", "org.mongodb:bson:5.8.0") + library("caffeine", "com.github.ben-manes.caffeine:caffeine:3.2.4") } create("testlibs") { - version("junit", "5.10.2") + version("junit", "5.14.4") library("junit-jupiter", "org.junit.jupiter", "junit-jupiter").versionRef("junit") library("junit-params", "org.junit.jupiter", "junit-jupiter-params").versionRef("junit") + library("junit-platform-launcher", "org.junit.platform", "junit-platform-launcher").version("1.14.4") + library("junit-pioneer", "org.junit-pioneer", "junit-pioneer").version("2.3.0") bundle("junit", listOf("junit-jupiter", "junit-params")) version("mockito", "5.+") library("mockito-core", "org.mockito", "mockito-core").versionRef("mockito") - version("slf4j", "2.0.13") + version("slf4j", "2.0.17") library("slf4j-noop", "org.slf4j", "slf4j-simple").versionRef("slf4j") } } diff --git a/shell.nix b/shell.nix new file mode 100644 index 00000000..115ed7eb --- /dev/null +++ b/shell.nix @@ -0,0 +1,5 @@ +{ pkgs ? import {}, ... }: + +pkgs.mkShell { + packages = with pkgs; [jdk25 nodejs_24]; +} diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/CityDeserializer.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/CityDeserializer.java index a37da517..69f22e0c 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/CityDeserializer.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/CityDeserializer.java @@ -6,19 +6,17 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.City; -import java.io.IOException; - /** * Deserializer for cities from id or names. */ -public class CityDeserializer extends JsonDeserializer { +public class CityDeserializer extends ValueDeserializer { @Override - public City deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public City deserialize(JsonParser p, DeserializationContext ctxt) { if (p.isExpectedNumberIntToken()) { return City.fromId(p.getValueAsInt()); } diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/DataCenterDeserializer.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/DataCenterDeserializer.java index 3cc03708..ecf09f45 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/DataCenterDeserializer.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/DataCenterDeserializer.java @@ -6,20 +6,18 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.worlds.DataCenter; import de.chojo.universalis.worlds.Worlds; -import java.io.IOException; - /** * Deserializer for data center */ -public class DataCenterDeserializer extends JsonDeserializer { +public class DataCenterDeserializer extends ValueDeserializer { @Override - public DataCenter deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public DataCenter deserialize(JsonParser p, DeserializationContext ctxt) { if (p.isExpectedNumberIntToken()) { return Worlds.datacenterById(p.getValueAsInt()); } diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/ItemDeserializer.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/ItemDeserializer.java index ed26420c..ce739f74 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/ItemDeserializer.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/ItemDeserializer.java @@ -6,18 +6,16 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.Item; import de.chojo.universalis.provider.NameSupplier; -import java.io.IOException; - /** * Deserializer for {@link Item} */ -public class ItemDeserializer extends JsonDeserializer { +public class ItemDeserializer extends ValueDeserializer { private final NameSupplier itemNameSupplier; /** @@ -30,7 +28,7 @@ public ItemDeserializer(NameSupplier itemNameSupplier) { } @Override - public Item deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public Item deserialize(JsonParser p, DeserializationContext ctxt) { return Item.build(itemNameSupplier, p.getValueAsInt()); } } diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/MillisDateTimeConverter.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/MillisDateTimeConverter.java index dec64747..92411d0f 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/MillisDateTimeConverter.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/MillisDateTimeConverter.java @@ -6,7 +6,7 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.databind.util.StdConverter; +import tools.jackson.databind.util.StdConverter; import java.time.Instant; import java.time.LocalDateTime; diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/RegionDeserializer.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/RegionDeserializer.java index 45e26b99..1a9ef8e2 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/RegionDeserializer.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/RegionDeserializer.java @@ -6,22 +6,20 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.worlds.Region; import de.chojo.universalis.worlds.Worlds; -import java.io.IOException; - /** * Deserializer for {@link Region} */ -public class RegionDeserializer extends JsonDeserializer { +public class RegionDeserializer extends ValueDeserializer { @Override - public Region deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { - return Worlds.regionByName(p.getText()); + public Region deserialize(JsonParser p, DeserializationContext ctxt) { + return Worlds.regionByName(p.getString()); } } diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/SecondsDateTimeConverter.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/SecondsDateTimeConverter.java index 73769b9c..b0eb040e 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/SecondsDateTimeConverter.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/SecondsDateTimeConverter.java @@ -6,7 +6,7 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.databind.util.StdConverter; +import tools.jackson.databind.util.StdConverter; import java.time.Instant; import java.time.LocalDateTime; diff --git a/universalis-core/src/main/java/de/chojo/universalis/deserializer/WorldDeserializer.java b/universalis-core/src/main/java/de/chojo/universalis/deserializer/WorldDeserializer.java index 82553e9a..099e4183 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/deserializer/WorldDeserializer.java +++ b/universalis-core/src/main/java/de/chojo/universalis/deserializer/WorldDeserializer.java @@ -6,20 +6,18 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.worlds.World; import de.chojo.universalis.worlds.Worlds; -import java.io.IOException; - /** * Deserializer to deserialize world ids and names. */ -public class WorldDeserializer extends JsonDeserializer { +public class WorldDeserializer extends ValueDeserializer { @Override - public World deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public World deserialize(JsonParser p, DeserializationContext ctxt) { if (p.isExpectedNumberIntToken()) { return Worlds.worldById(p.getValueAsInt()); } diff --git a/universalis-core/src/main/java/de/chojo/universalis/entities/views/ListingView.java b/universalis-core/src/main/java/de/chojo/universalis/entities/views/ListingView.java index acbe65a8..248df13b 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/entities/views/ListingView.java +++ b/universalis-core/src/main/java/de/chojo/universalis/entities/views/ListingView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.entities.City; import de.chojo.universalis.entities.Creator; @@ -107,11 +107,11 @@ public boolean equals(Object o) { if (onMannequin != that.onMannequin) return false; if (retainerCity != that.retainerCity) return false; if (total != that.total) return false; + if (tax != that.tax) return false; if (!Objects.equals(creatorId, that.creatorId)) return false; if (!Objects.equals(materia, that.materia)) return false; if (!Objects.equals(retainerId, that.retainerId)) return false; if (!Objects.equals(retainerName, that.retainerName)) return false; - if (!Objects.equals(tax, that.tax)) return false; return Objects.equals(sellerId, that.sellerId); } @@ -125,7 +125,7 @@ public int hashCode() { result = 31 * result + (isCrafted() ? 1 : 0); result = 31 * result + (materia != null ? materia.hashCode() : 0); result = 31 * result + (onMannequin ? 1 : 0); - result = 31 * result + retainerCity.ordinal(); + result = 31 * result + (retainerCity == null ? 0 : retainerCity.ordinal()); result = 31 * result + (retainerId != null ? retainerId.hashCode() : 0); result = 31 * result + (retainerName != null ? retainerName.hashCode() : 0); result = 31 * result + (sellerId != null ? sellerId.hashCode() : 0); diff --git a/universalis-core/src/main/java/de/chojo/universalis/provider/items/Items.java b/universalis-core/src/main/java/de/chojo/universalis/provider/items/Items.java index 6996bd60..ab23b7f4 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/provider/items/Items.java +++ b/universalis-core/src/main/java/de/chojo/universalis/provider/items/Items.java @@ -6,7 +6,8 @@ package de.chojo.universalis.provider.items; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; import de.chojo.universalis.entities.Name; import de.chojo.universalis.provider.NameSupplier; @@ -15,6 +16,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Collections; import java.util.HashMap; import java.util.Locale; @@ -24,6 +26,13 @@ * A class which is able to act as a {@link NameSupplier} for items */ public class Items implements NameSupplier { + /** + * Default source URL for the item name dump. + */ + public static final URI DEFAULT_SOURCE_URI = + URI.create("https://raw.githubusercontent.com/ffxiv-teamcraft/ffxiv-teamcraft/staging/libs/data/src/lib/json/items.json"); + private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30); + private final Map ids; private final Map en = new HashMap<>(); private final Map de = new HashMap<>(); @@ -33,37 +42,64 @@ public class Items implements NameSupplier { private Items(Map ids) { this.ids = ids; for (var entry : ids.entrySet()) { - en.put(entry.getValue().english().toLowerCase(Locale.ROOT), entry.getKey()); - de.put(entry.getValue().german().toLowerCase(Locale.ROOT), entry.getKey()); - fr.put(entry.getValue().french().toLowerCase(Locale.ROOT), entry.getKey()); - jp.put(entry.getValue().japanese().toLowerCase(Locale.ROOT), entry.getKey()); + Name name = entry.getValue(); + Integer id = entry.getKey(); + putLower(en, name.english(), id); + putLower(de, name.german(), id); + putLower(fr, name.french(), id); + putLower(jp, name.japanese(), id); } } + private static void putLower(Map target, String key, Integer id) { + if (key == null || key.isBlank()) return; + target.put(key.toLowerCase(Locale.ROOT), id); + } + /** - * Create a new items instance which will load the items from a GitHub dump. + * Create a new items instance which will load the items from the {@link #DEFAULT_SOURCE_URI default source}. * * @return item instance * @throws IOException if the response could not be read * @throws InterruptedException if the current thread gets interrupted */ public static Items create() throws IOException, InterruptedException { - HttpClient client = HttpClient.newBuilder().build(); - HttpResponse response = client.send(HttpRequest.newBuilder() - .uri(URI.create("https://raw.githubusercontent.com/ffxiv-teamcraft/ffxiv-teamcraft/staging/libs/data/src/lib/json/items.json")) - .GET() - .build(), HttpResponse.BodyHandlers.ofString()); - ObjectMapper mapper = new ObjectMapper(); - String body = response.body(); - Map names = mapper.readValue(body, mapper.getTypeFactory() - .constructMapType(Map.class, String.class, Name.class)); + return create(DEFAULT_SOURCE_URI); + } - Map idNames = new HashMap<>(); + /** + * Create a new items instance which will load the items from the given URL. + * + * @param source source URI + * @return item instance + * @throws IOException if the response could not be read or is not 2xx + * @throws InterruptedException if the current thread gets interrupted + */ + public static Items create(URI source) throws IOException, InterruptedException { + try (HttpClient client = HttpClient.newBuilder().connectTimeout(DEFAULT_TIMEOUT).build()) { + HttpResponse response = client.send(HttpRequest.newBuilder() + .uri(source) + .timeout(DEFAULT_TIMEOUT) + .GET() + .build(), HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() / 100 != 2) { + throw new IOException("Failed to load items from " + source + + " — status " + response.statusCode()); + } + ObjectMapper mapper = JsonMapper.builder().build(); + Map names = mapper.readValue(response.body(), mapper.getTypeFactory() + .constructMapType(Map.class, String.class, Name.class)); - for (Map.Entry entry : names.entrySet()) { - idNames.put(Integer.parseInt(entry.getKey()), entry.getValue()); + Map idNames = new HashMap<>(); + for (Map.Entry entry : names.entrySet()) { + try { + idNames.put(Integer.parseInt(entry.getKey()), entry.getValue()); + } catch (NumberFormatException e) { + throw new IOException("Item id '" + entry.getKey() + "' from " + source + " is not an integer", e); + } + } + return new Items(idNames); } - return new Items(idNames); } @Override diff --git a/universalis-core/src/main/java/de/chojo/universalis/worlds/World.java b/universalis-core/src/main/java/de/chojo/universalis/worlds/World.java index 0102cb84..0be6b12e 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/worlds/World.java +++ b/universalis-core/src/main/java/de/chojo/universalis/worlds/World.java @@ -45,6 +45,11 @@ public boolean equals(Object obj) { return id == world.id(); } + @Override + public int hashCode() { + return Integer.hashCode(id); + } + @Override public DataCenter dataCenter() { return dataCenter; diff --git a/universalis-core/src/main/java/de/chojo/universalis/worlds/Worlds.java b/universalis-core/src/main/java/de/chojo/universalis/worlds/Worlds.java index 83d76214..e4823509 100644 --- a/universalis-core/src/main/java/de/chojo/universalis/worlds/Worlds.java +++ b/universalis-core/src/main/java/de/chojo/universalis/worlds/Worlds.java @@ -10,18 +10,18 @@ import org.jetbrains.annotations.Nullable; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Class containing the currently valid regions, datacenters and worlds. */ @SuppressWarnings("unused") public class Worlds { - private static final Map ids = new HashMap<>(); - private static final Map names = new HashMap<>(); + private static final Map ids = new ConcurrentHashMap<>(); + private static final Map names = new ConcurrentHashMap<>(); private static final Europe europe = new Europe(); private static final NorthAmerica northAmerica = new NorthAmerica(); private static final Oceania oceania = new Oceania(); @@ -1000,7 +1000,7 @@ public List worlds() { @Override public int id() { - return 0; + return 11; } @Override @@ -1053,7 +1053,7 @@ public List worlds() { @Override public int id() { - return 0; + return 12; } @Override @@ -1102,7 +1102,7 @@ public List worlds() { @Override public int id() { - return 0; + return 13; } @Override @@ -1155,7 +1155,7 @@ public List worlds() { @Override public int id() { - return 0; + return 14; } @Override diff --git a/universalis-core/src/test/java/de/chojo/universalis/worlds/WorldsTest.java b/universalis-core/src/test/java/de/chojo/universalis/worlds/WorldsTest.java new file mode 100644 index 00000000..d0a52838 --- /dev/null +++ b/universalis-core/src/test/java/de/chojo/universalis/worlds/WorldsTest.java @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.universalis.worlds; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression coverage for the world/datacenter registry. + */ +class WorldsTest { + + @Test + void allDataCenterIdsAreDistinct() { + Set seen = new HashSet<>(); + for (Region region : Worlds.regions()) { + for (DataCenter dc : region.datacenters()) { + assertTrue(seen.add(dc.id()), + "duplicate datacenter id " + dc.id() + " on " + dc.name()); + } + } + } + + @Test + void datacenterByIdRoundTripsAllKnownDatacenters() { + for (Region region : Worlds.regions()) { + for (DataCenter dc : region.datacenters()) { + DataCenter looked = Worlds.datacenterById(dc.id()); + assertNotNull(looked, "datacenter " + dc.name() + " not found by id " + dc.id()); + assertEquals(dc.name(), looked.name(), + "datacenter id " + dc.id() + " resolved to wrong datacenter"); + } + } + } + + @Test + void datacenterByIdReturnsNullForUnknown() { + assertNull(Worlds.datacenterById(Integer.MIN_VALUE)); + } + + @Test + void worldOfEqualsAndHashCodeAreConsistent() { + World a = World.of("Odin", 66, null); + World b = World.of("Odin", 66, null); + World c = World.of("Odin", 67, null); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + assertNotEquals(a, c); + } + + @Test + void worldByIdIsThreadSafe() throws InterruptedException, ExecutionException { + int threads = 16; + int iterations = 500; + var exec = Executors.newFixedThreadPool(threads); + try { + List> futures = new java.util.ArrayList<>(); + for (int t = 0; t < threads; t++) { + futures.add(exec.submit(() -> { + for (int i = 0; i < iterations; i++) { + assertNotNull(Worlds.worldById(66)); + assertNotNull(Worlds.worldById(100_000 + (i % 32))); + } + })); + } + for (Future f : futures) { + f.get(); + } + } finally { + exec.shutdownNow(); + } + } +} diff --git a/universalis-rest/build.gradle.kts b/universalis-rest/build.gradle.kts index c3e46397..d64b78d7 100644 --- a/universalis-rest/build.gradle.kts +++ b/universalis-rest/build.gradle.kts @@ -3,4 +3,5 @@ dependencies { // Rest api(libs.bucket4j) api(libs.apache.httpclient) + testImplementation(rootProject.testlibs.junit.pioneer) } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/DataCenterKeyDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/DataCenterKeyDeserializer.java index 909e9be7..dc7a95f0 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/DataCenterKeyDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/DataCenterKeyDeserializer.java @@ -6,21 +6,25 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.KeyDeserializer; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.KeyDeserializer; import de.chojo.universalis.worlds.Worlds; +import org.slf4j.Logger; -import java.io.IOException; +import static org.slf4j.LoggerFactory.getLogger; /** * Deserializer for worlds as a key of a map */ public class DataCenterKeyDeserializer extends KeyDeserializer { + private static final Logger log = getLogger(DataCenterKeyDeserializer.class); + @Override public Object deserializeKey(String key, DeserializationContext ctxt) { try { return Worlds.datacenterById(Integer.parseInt(key)); } catch (NumberFormatException e) { + log.trace("Datacenter key '{}' is not an integer, resolving by name", key, e); return Worlds.datacenterByName(key); } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/EmptyStringDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/EmptyStringDeserializer.java index dce6f359..10dc4b66 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/EmptyStringDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/EmptyStringDeserializer.java @@ -6,19 +6,17 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.deser.std.StringDeserializer; - -import java.io.IOException; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.deser.jdk.StringDeserializer; /** * Serializer to change empty string to null values. */ -public class EmptyStringDeserializer extends JsonDeserializer { +public class EmptyStringDeserializer extends ValueDeserializer { @Override - public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public String deserialize(JsonParser p, DeserializationContext ctxt) { String deserialize = StringDeserializer.instance.deserialize(p, ctxt); if (deserialize == null || deserialize.isBlank()) { return null; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/ListingDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/ListingDeserializer.java index 535d61f9..b3c4250d 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/ListingDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/ListingDeserializer.java @@ -6,20 +6,18 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.Listing; import de.chojo.universalis.entities.views.ListingView; -import java.io.IOException; - /** * Deserializer for {@link Listing} */ -public class ListingDeserializer extends JsonDeserializer { +public class ListingDeserializer extends ValueDeserializer { @Override - public Listing deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public Listing deserialize(JsonParser p, DeserializationContext ctxt) { return ctxt.readValue(p, ListingView.class).toListing(); } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/WorldKeyDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/WorldKeyDeserializer.java index ec866a83..26a71731 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/WorldKeyDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/WorldKeyDeserializer.java @@ -6,21 +6,25 @@ package de.chojo.universalis.deserializer; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.KeyDeserializer; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.KeyDeserializer; import de.chojo.universalis.worlds.Worlds; +import org.slf4j.Logger; -import java.io.IOException; +import static org.slf4j.LoggerFactory.getLogger; /** * Deserializer for worlds as a key of a map */ public class WorldKeyDeserializer extends KeyDeserializer { + private static final Logger log = getLogger(WorldKeyDeserializer.class); + @Override public Object deserializeKey(String key, DeserializationContext ctxt) { try { return Worlds.worldById(Integer.parseInt(key)); } catch (NumberFormatException e) { + log.trace("World key '{}' is not an integer, resolving by name", key, e); return Worlds.worldByName(key); } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/ContentResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/ContentResponseDeserializer.java index a3413fcb..209cbe77 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/ContentResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/ContentResponseDeserializer.java @@ -6,21 +6,20 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.rest.response.extra.ContentResponse; -import java.io.IOException; import java.util.HashMap; import java.util.Map; /** * Deserializer for {@link ContentResponse} */ -public class ContentResponseDeserializer extends JsonDeserializer { +public class ContentResponseDeserializer extends ValueDeserializer { @Override - public ContentResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public ContentResponse deserialize(JsonParser p, DeserializationContext ctxt) { Map map = ctxt.readValue(p, ctxt.getTypeFactory() .constructMapType(HashMap.class, String.class, Object.class)); return new ContentResponse(map); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/DataCenterResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/DataCenterResponseDeserializer.java index 10306e60..256f0a58 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/DataCenterResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/DataCenterResponseDeserializer.java @@ -6,21 +6,20 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.DataCenter; import de.chojo.universalis.rest.response.DataCentersResponse; -import java.io.IOException; import java.util.List; /** * Deserializer for {@link DataCentersResponse} */ -public class DataCenterResponseDeserializer extends JsonDeserializer { +public class DataCenterResponseDeserializer extends ValueDeserializer { @Override - public DataCentersResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public DataCentersResponse deserialize(JsonParser p, DeserializationContext ctxt) { List contentType = ctxt.readValue(p, ctxt.getTypeFactory() .constructCollectionType(List.class, DataCenter.class)); return new DataCentersResponse(contentType); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/HistoryResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/HistoryResponseDeserializer.java index 4ffb8bf0..2e123adf 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/HistoryResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/HistoryResponseDeserializer.java @@ -6,16 +6,15 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.MinimizedSale; import de.chojo.universalis.entities.QualityIndicator; import de.chojo.universalis.entities.views.HistoryView; import de.chojo.universalis.entities.views.MinimizedSaleView; import de.chojo.universalis.rest.response.HistoryResponse; -import java.io.IOException; import java.util.Collections; import java.util.List; import java.util.Map; @@ -24,9 +23,9 @@ /** * Deserializer for {@link HistoryResponse} */ -public class HistoryResponseDeserializer extends JsonDeserializer { +public class HistoryResponseDeserializer extends ValueDeserializer { @Override - public HistoryResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public HistoryResponse deserialize(JsonParser p, DeserializationContext ctxt) { var view = ctxt.readValue(p, HistoryView.class); var item = view.item(); var world = view.world(); @@ -40,9 +39,9 @@ public HistoryResponse deserialize(JsonParser p, DeserializationContext ctxt) th var saleVelocity = QualityIndicator.of(view.regularSaleVelocity(), view.nqSaleVelocity(), view.hqSaleVelocity()); var histogramG = view.stackSizeHistogram().entrySet().stream() .collect(Collectors.toMap(e -> Integer.valueOf(e.getKey()), Map.Entry::getValue)); - var histogramNQ = view.stackSizeHistogram().entrySet().stream() + var histogramNQ = view.stackSizeHistogramNQ().entrySet().stream() .collect(Collectors.toMap(e -> Integer.valueOf(e.getKey()), Map.Entry::getValue)); - var histogramHQ = view.stackSizeHistogram().entrySet().stream() + var histogramHQ = view.stackSizeHistogramHQ().entrySet().stream() .collect(Collectors.toMap(e -> Integer.valueOf(e.getKey()), Map.Entry::getValue)); var histogram = QualityIndicator.of(histogramG, histogramNQ, histogramHQ); return new HistoryResponse(item, world, datacenter, region, lastUploadTime, sales, saleVelocity, diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketBoardResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketBoardResponseDeserializer.java index 016ec5ff..5ea5f6f1 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketBoardResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketBoardResponseDeserializer.java @@ -6,9 +6,9 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.QualityIndicator; import de.chojo.universalis.entities.views.CurrentlyShownView; import de.chojo.universalis.entities.views.ListingView; @@ -17,7 +17,6 @@ import de.chojo.universalis.worlds.World; import de.chojo.universalis.worlds.Worlds; -import java.io.IOException; import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneOffset; @@ -28,9 +27,9 @@ /** * Deserializer for {@link MarketBoardResponse} */ -public class MarketBoardResponseDeserializer extends JsonDeserializer { +public class MarketBoardResponseDeserializer extends ValueDeserializer { @Override - public MarketBoardResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public MarketBoardResponse deserialize(JsonParser p, DeserializationContext ctxt) { var view = ctxt.readValue(p, CurrentlyShownView.class); var item = view.item(); var world = view.world(); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketableResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketableResponseDeserializer.java index 13cca7ce..dcbc1102 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketableResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/MarketableResponseDeserializer.java @@ -6,21 +6,20 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.Item; import de.chojo.universalis.rest.response.MarketableResponse; -import java.io.IOException; import java.util.List; /** * Deserializer for {@link MarketableResponse} */ -public class MarketableResponseDeserializer extends JsonDeserializer { +public class MarketableResponseDeserializer extends ValueDeserializer { @Override - public MarketableResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public MarketableResponse deserialize(JsonParser p, DeserializationContext ctxt) { List items = ctxt.readValue(p, ctxt.getTypeFactory().constructCollectionType(List.class, Item.class)); return new MarketableResponse(items); } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/TaxRatesResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/TaxRatesResponseDeserializer.java index ce0eb39c..9b1d7350 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/TaxRatesResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/TaxRatesResponseDeserializer.java @@ -6,22 +6,21 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.City; import de.chojo.universalis.rest.response.TaxRatesResponse; -import java.io.IOException; import java.util.EnumMap; import java.util.Map; /** * Deserializer for {@link TaxRatesResponse} */ -public class TaxRatesResponseDeserializer extends JsonDeserializer { +public class TaxRatesResponseDeserializer extends ValueDeserializer { @Override - public TaxRatesResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public TaxRatesResponse deserialize(JsonParser p, DeserializationContext ctxt) { Map taxRates = ctxt.readValue(p, ctxt.getTypeFactory() .constructMapType(EnumMap.class, City.class, Integer.class)); return new TaxRatesResponse(taxRates); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/UploaderUploadCountsResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/UploaderUploadCountsResponseDeserializer.java index a4eb55b0..a8c486d9 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/UploaderUploadCountsResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/UploaderUploadCountsResponseDeserializer.java @@ -6,21 +6,20 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.views.SourceUploadCountView; import de.chojo.universalis.rest.response.extra.stats.UploaderUploadCountResponse; -import java.io.IOException; import java.util.List; /** * Deserializer for {@link UploaderUploadCountResponse} */ -public class UploaderUploadCountsResponseDeserializer extends JsonDeserializer { +public class UploaderUploadCountsResponseDeserializer extends ValueDeserializer { @Override - public UploaderUploadCountResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public UploaderUploadCountResponse deserialize(JsonParser p, DeserializationContext ctxt) { List counts = ctxt.readValue(p, ctxt.getTypeFactory() .constructCollectionType(List.class, SourceUploadCountView.class)); return new UploaderUploadCountResponse(counts); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldUploadCountResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldUploadCountResponseDeserializer.java index 617e0126..0be5f421 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldUploadCountResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldUploadCountResponseDeserializer.java @@ -6,23 +6,22 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.views.WorldUploadCountView; import de.chojo.universalis.rest.response.extra.stats.WorldUploadCountResponse; import de.chojo.universalis.worlds.World; -import java.io.IOException; import java.util.HashMap; import java.util.Map; /** * Deserializer for {@link WorldUploadCountResponse} */ -public class WorldUploadCountResponseDeserializer extends JsonDeserializer { +public class WorldUploadCountResponseDeserializer extends ValueDeserializer { @Override - public WorldUploadCountResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public WorldUploadCountResponse deserialize(JsonParser p, DeserializationContext ctxt) { Map map = ctxt.readValue(p, ctxt.getTypeFactory() .constructMapType(HashMap.class, World.class, WorldUploadCountView.class)); return new WorldUploadCountResponse(map); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldsResponseDeserializer.java b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldsResponseDeserializer.java index a84149cd..75e2dc5f 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldsResponseDeserializer.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/deserializer/response/WorldsResponseDeserializer.java @@ -6,23 +6,22 @@ package de.chojo.universalis.deserializer.response; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.ValueDeserializer; import de.chojo.universalis.entities.World; import de.chojo.universalis.rest.response.WorldsResponse; import de.chojo.universalis.worlds.Worlds; -import java.io.IOException; import java.util.List; import java.util.Objects; /** * Deserializer for {@link WorldsResponse} */ -public class WorldsResponseDeserializer extends JsonDeserializer { +public class WorldsResponseDeserializer extends ValueDeserializer { @Override - public WorldsResponse deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + public WorldsResponse deserialize(JsonParser p, DeserializationContext ctxt) { List worlds = ctxt.readValue(p, ctxt.getTypeFactory().constructCollectionType(List.class, World.class)); worlds = worlds.stream().filter(Objects::nonNull).filter(w -> w.id() != 0).toList(); diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/MinimizedSale.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/MinimizedSale.java index 47318c28..c1d5adf8 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/MinimizedSale.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/MinimizedSale.java @@ -6,7 +6,7 @@ package de.chojo.universalis.entities; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.worlds.World; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/WorldItemRecently.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/WorldItemRecently.java index f35bc103..461aadc4 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/WorldItemRecently.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/WorldItemRecently.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.rest.response.extra.stats.LeastRecentlyUpdatedResponse; import de.chojo.universalis.rest.response.extra.stats.MostRecentlyUpdatedResponse; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/CurrentlyShownView.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/CurrentlyShownView.java index 3108b7f3..8fc6b1c0 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/CurrentlyShownView.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/CurrentlyShownView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.MillisDateTimeConverter; import de.chojo.universalis.entities.Item; import de.chojo.universalis.worlds.DataCenter; @@ -52,16 +52,16 @@ public record CurrentlyShownView(@JsonProperty("itemID") Item item, ) { @Override public Map stackSizeHistogramHQ() { - return stackSizeHistogramHQ == null? Collections.emptyMap() : stackSizeHistogramHQ; + return stackSizeHistogramHQ == null ? Collections.emptyMap() : stackSizeHistogramHQ; } @Override public Map stackSizeHistogramNQ() { - return stackSizeHistogramHQ == null? Collections.emptyMap() : stackSizeHistogramNQ; + return stackSizeHistogramNQ == null ? Collections.emptyMap() : stackSizeHistogramNQ; } @Override public Map stackSizeHistogram() { - return stackSizeHistogramHQ == null? Collections.emptyMap() : stackSizeHistogram; + return stackSizeHistogram == null ? Collections.emptyMap() : stackSizeHistogram; } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/HistoryView.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/HistoryView.java index a216bd6c..e4d7e796 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/HistoryView.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/HistoryView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.MillisDateTimeConverter; import de.chojo.universalis.entities.Item; import de.chojo.universalis.worlds.DataCenter; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/MinimizedSaleView.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/MinimizedSaleView.java index c16f00c9..722847d9 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/MinimizedSaleView.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/MinimizedSaleView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.entities.MinimizedPrice; import de.chojo.universalis.entities.MinimizedSale; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/SaleView.java b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/SaleView.java index 4dab25b0..6f1e0bdc 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/entities/views/SaleView.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/entities/views/SaleView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.entities.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.entities.Price; import de.chojo.universalis.entities.Sale; diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRest.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRest.java index c68f0e85..8a4494c9 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRest.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRest.java @@ -19,7 +19,7 @@ /** * Class to access the universalis api. */ -public interface UniversalisRest { +public interface UniversalisRest extends AutoCloseable { /** * Get a api with default settings. * @@ -95,4 +95,11 @@ static UniversalisRestBuilder builder() { */ @CheckReturnValue Extra extra(); + + /** + * Shuts down the underlying HTTP client and executor. Any in-flight or queued + * asynchronous requests will be cancelled. + */ + @Override + void close(); } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestBuilder.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestBuilder.java index 6c71e0b3..8c6ad48b 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestBuilder.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestBuilder.java @@ -10,17 +10,34 @@ import de.chojo.universalis.provider.items.Items; import org.jetbrains.annotations.CheckReturnValue; +import java.net.URI; import java.net.http.HttpClient; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicInteger; /** * Class to build an universalis rest client */ public class UniversalisRestBuilder { + /** + * Default base URI of the universalis API. + */ + public static final URI DEFAULT_BASE_URI = URI.create("https://universalis.app/api/v2"); + private HttpClient http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); - private ScheduledExecutorService executorService = Executors.newScheduledThreadPool(2); + private ScheduledExecutorService executorService = defaultExecutor(); private NameSupplier nameSupplier = NameSupplier.EMPTY; + private URI baseUri = DEFAULT_BASE_URI; + + private static ScheduledExecutorService defaultExecutor() { + AtomicInteger counter = new AtomicInteger(); + return Executors.newScheduledThreadPool(2, r -> { + Thread t = new Thread(r, "universalis-rest-" + counter.incrementAndGet()); + t.setDaemon(true); + return t; + }); + } /** * Set the http client used to send requests to universalis @@ -58,6 +75,18 @@ public UniversalisRestBuilder setItemNameSupplier(NameSupplier nameSupplier) { return this; } + /** + * Override the base URI of the universalis API. Defaults to {@link #DEFAULT_BASE_URI}. + * + * @param baseUri base URI + * @return builder + */ + @CheckReturnValue + public UniversalisRestBuilder setBaseUri(URI baseUri) { + this.baseUri = baseUri; + return this; + } + /** * Build the api. The instance is ready to use. * @@ -65,6 +94,6 @@ public UniversalisRestBuilder setItemNameSupplier(NameSupplier nameSupplier) { */ @CheckReturnValue public UniversalisRest build() { - return new UniversalisRestImpl(http, executorService, nameSupplier); + return new UniversalisRestImpl(http, executorService, nameSupplier, baseUri); } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestImpl.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestImpl.java index 656c4ce5..700e8414 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestImpl.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/UniversalisRestImpl.java @@ -6,7 +6,8 @@ package de.chojo.universalis.rest; -import com.fasterxml.jackson.databind.ObjectMapper; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; import de.chojo.universalis.exceptions.ErrorResponseException; import de.chojo.universalis.exceptions.RequestException; import de.chojo.universalis.exceptions.ResponseException; @@ -14,10 +15,11 @@ import de.chojo.universalis.rest.requests.Buckets; import de.chojo.universalis.rest.requests.Mapper; import de.chojo.universalis.rest.routes.api.DataCentersRequest; -import de.chojo.universalis.rest.routes.api.MarketBoardRequest; import de.chojo.universalis.rest.routes.api.MarketableRequest; import de.chojo.universalis.rest.routes.api.WorldsRequest; import de.chojo.universalis.rest.routes.api.history.BlankHistoryRequest; +import de.chojo.universalis.rest.routes.api.marketboard.BlankMarketBoardRequest; +import de.chojo.universalis.rest.routes.api.taxrates.BlankTaxRatesRequest; import de.chojo.universalis.rest.routes.requests.DataCentersRequestImpl; import de.chojo.universalis.rest.routes.requests.HistoryRequestImpl; import de.chojo.universalis.rest.routes.requests.MarketBoardRequestImpl; @@ -47,10 +49,11 @@ public class UniversalisRestImpl implements UniversalisRest { private static final Logger log = getLogger(UniversalisRestImpl.class); private final Bucket xivapi = Buckets.newUniversalisBucket(); - private final HttpClient http;// = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); + private final HttpClient http; private final ObjectMapper objectMapper; - private final ScheduledExecutorService executorService;// = Executors.newScheduledThreadPool(2); + private final ScheduledExecutorService executorService; private final Extra extra; + private final URI baseUri; /** * Create a new universalis rest client @@ -58,12 +61,14 @@ public class UniversalisRestImpl implements UniversalisRest { * @param http http client * @param executorService executor service * @param itemNameSupplier item name supplier + * @param baseUri base URI of the universalis API (e.g. {@code https://universalis.app/api/v2}) */ - public UniversalisRestImpl(HttpClient http, ScheduledExecutorService executorService, NameSupplier itemNameSupplier) { + public UniversalisRestImpl(HttpClient http, ScheduledExecutorService executorService, NameSupplier itemNameSupplier, URI baseUri) { this.http = http; this.executorService = executorService; this.objectMapper = Mapper.create(itemNameSupplier); this.extra = new Extra(this); + this.baseUri = baseUri; } /** @@ -81,7 +86,7 @@ public HttpClient http() { * @return new uri builder */ public URIBuilder uri() { - return new URIBuilder().setScheme("https").setHost("universalis.app").appendPathSegments("api", "v2"); + return new URIBuilder(baseUri); } /** @@ -95,7 +100,7 @@ public ObjectMapper objectMapper() { @Override @CheckReturnValue - public MarketBoardRequest marketBoard() { + public BlankMarketBoardRequest marketBoard() { return new MarketBoardRequestImpl(this); } @@ -119,7 +124,7 @@ public BlankHistoryRequest history() { @Override @CheckReturnValue - public TaxRatesRequestImpl taxRates() { + public BlankTaxRatesRequest taxRates() { return new TaxRatesRequestImpl(this); } @@ -228,7 +233,10 @@ private T getAndMapInternal(HttpRequest request, Class result) { try { log.trace("Requesting {}", request.uri()); response = http().send(request, HttpResponse.BodyHandlers.ofString()); - } catch (InterruptedException | IOException e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new ResponseException("Interrupted while reading the request", e); + } catch (IOException e) { throw new ResponseException("Error during reading the request", e); } @@ -239,8 +247,15 @@ private T getAndMapInternal(HttpRequest request, Class result) { } log.trace("Received\n{}", response.body()); return objectMapper().readValue(response.body(), result); - } catch (IOException e) { + } catch (JacksonException e) { throw new ResponseException("Error during request mapping", e); } } + + @Override + public void close() { + log.debug("Shutting down universalis rest client"); + executorService.shutdown(); + http.close(); + } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/Mapper.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/Mapper.java index 9ba80bfd..ee83cc4f 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/Mapper.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/Mapper.java @@ -8,10 +8,10 @@ import com.fasterxml.jackson.annotation.JsonAutoDetect; import com.fasterxml.jackson.annotation.PropertyAccessor; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; import de.chojo.universalis.deserializer.CityDeserializer; import de.chojo.universalis.deserializer.DataCenterDeserializer; import de.chojo.universalis.deserializer.DataCenterKeyDeserializer; @@ -65,11 +65,9 @@ public static ObjectMapper create(NameSupplier itemNameSupplier) { SimpleModule module = new SimpleModule(); module.addDeserializer(City.class, new CityDeserializer()) .addDeserializer(ContentResponse.class, new ContentResponseDeserializer()) - //.addDeserializer(Datacenter.class, new DataCenterDeserializer()) .addDeserializer(DataCentersResponse.class, new DataCenterResponseDeserializer()) .addDeserializer(String.class, new EmptyStringDeserializer()) .addDeserializer(HistoryResponse.class, new HistoryResponseDeserializer()) - //.addDeserializer(Instant.class, new SecondsDateTimeConverter()) .addDeserializer(Item.class, new ItemDeserializer(itemNameSupplier)) .addDeserializer(Listing.class, new ListingDeserializer()) .addDeserializer(MarketableResponse.class, new MarketableResponseDeserializer()) @@ -83,10 +81,11 @@ public static ObjectMapper create(NameSupplier itemNameSupplier) { .addDeserializer(DataCenter.class, new DataCenterDeserializer()) .addDeserializer(WorldsResponse.class, new WorldsResponseDeserializer()) .addDeserializer(WorldUploadCountResponse.class, new WorldUploadCountResponseDeserializer()); - return new JsonMapper() - .setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.NONE) - .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .registerModule(module); + return JsonMapper.builder() + .changeDefaultVisibility(v -> v.withVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.NONE)) + .enable(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .addModule(module) + .build(); } } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/RequestBuilder.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/RequestBuilder.java index eeaaf728..63e71abd 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/RequestBuilder.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/requests/RequestBuilder.java @@ -29,6 +29,19 @@ public class RequestBuilder implements Request { private final URIBuilder uriBuilder; private final Class result; private final Consumer postRetrievalHook; + private boolean hasScope; + + /** + * Records that a top-level scope (world/dataCenter/region) has been chosen. + * Throws if a scope is already set — request scopes are mutually exclusive. + */ + public void markScope(String name) { + if (hasScope) { + throw new IllegalStateException( + "A scope has already been set on this request; scopes are mutually exclusive. Attempted to add: " + name); + } + hasScope = true; + } /** * Create a new request builder diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/MarketBoardRequest.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/MarketBoardRequest.java index 55845454..42101a16 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/MarketBoardRequest.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/MarketBoardRequest.java @@ -6,7 +6,6 @@ package de.chojo.universalis.rest.routes.api; -import de.chojo.universalis.entities.Listing; import de.chojo.universalis.rest.requests.Request; import de.chojo.universalis.rest.response.MarketBoardResponse; import de.chojo.universalis.rest.routes.api.marketboard.RegionMarketBoardRequest; @@ -37,31 +36,6 @@ public interface MarketBoardRequest extends Request, Region @CheckReturnValue MarketBoardRequest historyLimit(int limit); - /** - * If the result should not have Gil sales tax (GST) factored in. - * GST is applied to all consumer purchases in-game, and is separate from the retainer city tax that impacts what sellers receive. - * By default, GST is factored in. - * - * @return request - * @deprecated Tax is no longer included in pricing. Tax is provided via {@link Listing#tax()} - */ - @CheckReturnValue - @Deprecated(forRemoval = true) - MarketBoardRequest noGst(); - - /** - * If the result should not have Gil sales tax (GST) factored in. - * GST is applied to all consumer purchases in-game, and is separate from the retainer city tax that impacts what sellers receive. - * By default, GST is factored in. - * - * @param noGst set to true to disable GST - * @return request - * @deprecated Tax is no longer included in pricing. Tax is provided via {@link Listing#tax()} - */ - @CheckReturnValue - @Deprecated(forRemoval = true) - MarketBoardRequest noGst(boolean noGst); - /** * Filter for HQ listings and entries. By default, both HQ and NQ listings and entries will be returned. * diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/DataCenterScope.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/DataCenterScope.java index b0ba689a..7e877ca1 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/DataCenterScope.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/DataCenterScope.java @@ -17,9 +17,8 @@ */ public interface DataCenterScope { /** - * Data center scope of the request. - *

- * Calling this again will remove other scopes. + * Data center scope of the request. Scopes are mutually exclusive — calling this + * after another scope method throws an {@link IllegalStateException}. * * @param dataCenter data center * @return request builder diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/RegionScope.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/RegionScope.java index d6e64750..94ef2931 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/RegionScope.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/RegionScope.java @@ -17,9 +17,8 @@ */ public interface RegionScope { /** - * Region scope of the request. - *

- * Calling this again will remove other scopes. + * Region scope of the request. Scopes are mutually exclusive — calling this + * after another scope method throws an {@link IllegalStateException}. * * @param region region * @return request builder diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/WorldScope.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/WorldScope.java index 6d8d49ad..3d6a376a 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/WorldScope.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/api/base/WorldScope.java @@ -17,9 +17,8 @@ */ public interface WorldScope { /** - * World scope of the request. - *

- * Calling this again will remove other scopes. + * World scope of the request. Scopes are mutually exclusive — calling this + * after another scope method throws an {@link IllegalStateException}. * * @param world world * @return request builder diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImpl.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImpl.java index 61a131ec..81e201c4 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImpl.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImpl.java @@ -39,6 +39,7 @@ public HistoryRequestImpl(UniversalisRestImpl rest) { @Override @CheckReturnValue public HistoryRequest region(Region region) { + markScope("region"); path(region.name()); return this; } @@ -46,6 +47,7 @@ public HistoryRequest region(Region region) { @Override @CheckReturnValue public HistoryRequest dataCenter(DataCenter dataCenter) { + markScope("dataCenter"); path(dataCenter.name()); return this; } @@ -53,6 +55,7 @@ public HistoryRequest dataCenter(DataCenter dataCenter) { @Override @CheckReturnValue public HistoryRequest world(World world) { + markScope("world"); path(world.id()); return this; } @@ -95,13 +98,19 @@ public HistoryRequest limit(int limit) { @Override @CheckReturnValue public HistoryRequest historyTime(Duration duration) { - parameter("entriesWithin", Math.abs(duration.getSeconds())); + if (duration.isNegative()) { + throw new IllegalArgumentException("historyTime duration must not be negative: " + duration); + } + parameter("entriesWithin", duration.toSeconds()); return this; } @Override @CheckReturnValue public HistoryRequest statsTime(Duration duration) { + if (duration.isNegative()) { + throw new IllegalArgumentException("statsTime duration must not be negative: " + duration); + } parameter("statsWithin", duration.toMillis()); return this; } diff --git a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImpl.java b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImpl.java index 82178a61..eb9c5fdf 100644 --- a/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImpl.java +++ b/universalis-rest/src/main/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImpl.java @@ -38,6 +38,7 @@ public MarketBoardRequestImpl(UniversalisRestImpl rest) { @Override @CheckReturnValue public MarketBoardRequest region(Region region) { + markScope("region"); path(region.name()); return this; } @@ -45,6 +46,7 @@ public MarketBoardRequest region(Region region) { @Override @CheckReturnValue public MarketBoardRequest dataCenter(DataCenter dataCenter) { + markScope("dataCenter"); path(dataCenter.name()); return this; } @@ -52,6 +54,7 @@ public MarketBoardRequest dataCenter(DataCenter dataCenter) { @Override @CheckReturnValue public MarketBoardRequest world(World world) { + markScope("world"); path(world.id()); return this; } @@ -98,21 +101,6 @@ public MarketBoardRequest historyLimit(int limit) { return this; } - @Override - @CheckReturnValue - @Deprecated(forRemoval = true) - public MarketBoardRequest noGst() { - return noGst(true); - } - - @Override - @CheckReturnValue - @Deprecated(forRemoval = true) - public MarketBoardRequest noGst(boolean noGst) { - parameter("noGst", noGst); - return this; - } - @Override @CheckReturnValue public MarketBoardRequest highQuality() { @@ -129,13 +117,19 @@ public MarketBoardRequest highQuality(boolean highQuality) { @Override @CheckReturnValue public MarketBoardRequest historyTime(Duration duration) { - parameter("entriesWithin", Math.abs(duration.getSeconds())); + if (duration.isNegative()) { + throw new IllegalArgumentException("historyTime duration must not be negative: " + duration); + } + parameter("entriesWithin", duration.toSeconds()); return this; } @Override @CheckReturnValue public MarketBoardRequest statsTime(Duration duration) { + if (duration.isNegative()) { + throw new IllegalArgumentException("statsTime duration must not be negative: " + duration); + } parameter("statsWithin", duration.toMillis()); return this; } diff --git a/universalis-rest/src/test/java/de/chojo/universalis/WorldsRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/WorldsRequestImplTest.java index 08a3d349..efab707f 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/WorldsRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/WorldsRequestImplTest.java @@ -7,14 +7,14 @@ package de.chojo.universalis; import de.chojo.universalis.rest.response.WorldsResponse; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; import static org.junit.jupiter.api.Assertions.assertFalse; class WorldsRequestImplTest { - @Test + @RetryingTest(3) public void test() { WorldsResponse complete = client().worlds().complete(); assertFalse(complete.worlds().isEmpty()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/deserializer/DeserializerFixtureTest.java b/universalis-rest/src/test/java/de/chojo/universalis/deserializer/DeserializerFixtureTest.java new file mode 100644 index 00000000..4159bff4 --- /dev/null +++ b/universalis-rest/src/test/java/de/chojo/universalis/deserializer/DeserializerFixtureTest.java @@ -0,0 +1,98 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.universalis.deserializer; + +import de.chojo.universalis.provider.NameSupplier; +import de.chojo.universalis.rest.requests.Mapper; +import de.chojo.universalis.rest.response.HistoryResponse; +import de.chojo.universalis.rest.response.MarketBoardResponse; +import org.junit.jupiter.api.Test; +import tools.jackson.databind.ObjectMapper; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Offline deserializer tests using inline JSON fixtures. These regressions catch + * bugs that only surface with distinct NQ/HQ/general histogram data — before the + * fix, {@code HistoryResponse} returned identical histograms and + * {@code CurrentlyShownView} silently emptied out NQ/general whenever HQ was null. + */ +class DeserializerFixtureTest { + private final ObjectMapper mapper = Mapper.create(NameSupplier.EMPTY); + + @Test + void historyResponseKeepsDistinctHistograms() { + String json = """ + { + "itemID": 5057, + "worldID": 66, + "lastUploadTime": 1700000000000, + "entries": [], + "dcName": "Light", + "regularSaleVelocity": 1.0, + "nqSaleVelocity": 2.0, + "hqSaleVelocity": 3.0, + "stackSizeHistogram": {"1": 10, "5": 20}, + "stackSizeHistogramNQ": {"1": 100}, + "stackSizeHistogramHQ": {"1": 999, "3": 42} + } + """; + + HistoryResponse response = mapper.readValue(json, HistoryResponse.class); + + Map general = response.stackSizeHistogram().general(); + Map nq = response.stackSizeHistogram().normalQuality(); + Map hq = response.stackSizeHistogram().highQuality(); + + assertEquals(Map.of(1, 10, 5, 20), general, "general histogram must come from stackSizeHistogram"); + assertEquals(Map.of(1, 100), nq, "NQ histogram must come from stackSizeHistogramNQ"); + assertEquals(Map.of(1, 999, 3, 42), hq, "HQ histogram must come from stackSizeHistogramHQ"); + } + + @Test + void marketBoardResponseGeneralAndNqSurviveMissingHq() { + String json = """ + { + "itemID": 5057, + "worldID": 66, + "lastUploadTime": 1700000000000, + "listings": [], + "recentHistory": [], + "dcName": "Light", + "regionName": "Europe", + "currentAveragePrice": 0.0, + "currentAveragePriceNQ": 0.0, + "currentAveragePriceHQ": 0.0, + "regularSaleVelocity": 0.0, + "nqSaleVelocity": 0.0, + "hqSaleVelocity": 0.0, + "averagePrice": 0.0, + "averagePriceNQ": 0.0, + "averagePriceHQ": 0.0, + "minPrice": 0, "minPriceNQ": 0, "minPriceHQ": 0, + "maxPrice": 0, "maxPriceNQ": 0, "maxPriceHQ": 0, + "stackSizeHistogram": {"1": 7}, + "stackSizeHistogramNQ": {"1": 3} + } + """; + + MarketBoardResponse response = mapper.readValue(json, MarketBoardResponse.class); + + assertEquals(7, response.stackSizeHistogram().general().get(1), + "general histogram must survive missing HQ"); + assertEquals(3, response.stackSizeHistogram().normalQuality().get(1), + "NQ histogram must survive missing HQ"); + assertNotNull(response.stackSizeHistogram().highQuality(), + "HQ histogram must be non-null (empty map) when missing from payload"); + assertTrue(response.stackSizeHistogram().highQuality().isEmpty(), + "missing HQ histogram should deserialize as empty"); + } +} diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/LiveApiRetry.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/LiveApiRetry.java new file mode 100644 index 00000000..dd1f4dc3 --- /dev/null +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/LiveApiRetry.java @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: LGPL-3.0-or-later + * + * Copyright (C) RainbowDashLabs and Contributor + */ + +package de.chojo.universalis.rest; + +import org.slf4j.Logger; + +import java.time.Duration; + +import static org.slf4j.LoggerFactory.getLogger; + +/** + * Retries an assertion block against the live universalis API a small number of times + * before failing. Use to reduce noise from transient network / upstream flakiness. + *

+ * For plain {@code @Test} methods prefer {@code @RetryingTest(3)} from junit-pioneer — + * this helper exists because pioneer's annotation does not compose with + * {@code @ParameterizedTest}. + */ +public final class LiveApiRetry { + private static final Logger log = getLogger(LiveApiRetry.class); + private static final int DEFAULT_ATTEMPTS = 3; + private static final Duration DEFAULT_DELAY = Duration.ofSeconds(2); + + private LiveApiRetry() {} + + /** + * Runs {@code action}, retrying up to {@value #DEFAULT_ATTEMPTS} times if it throws. + */ + public static void retry(ThrowingRunnable action) { + retry(DEFAULT_ATTEMPTS, DEFAULT_DELAY, action); + } + + /** + * Runs {@code action}, retrying up to {@code attempts} times if it throws, waiting + * {@code delay} between attempts. + */ + public static void retry(int attempts, Duration delay, ThrowingRunnable action) { + Throwable last = null; + for (int attempt = 1; attempt <= attempts; attempt++) { + try { + action.run(); + return; + } catch (Throwable t) { + last = t; + if (attempt < attempts) { + log.warn("Live API attempt {}/{} failed ({}), retrying in {}s", + attempt, attempts, t.getClass().getSimpleName(), delay.toSeconds()); + try { + Thread.sleep(delay.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError("Interrupted while waiting to retry", e); + } + } + } + } + if (last instanceof RuntimeException re) throw re; + if (last instanceof Error e) throw e; + throw new AssertionError("Live API failed after " + attempts + " attempts", last); + } + + /** + * Runnable that can throw any exception. + */ + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Throwable; + } +} diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/DataCentersRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/DataCentersRequestImplTest.java index 9be080b5..f31a4a59 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/DataCentersRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/DataCentersRequestImplTest.java @@ -8,13 +8,13 @@ import de.chojo.universalis.rest.response.DataCentersResponse; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; class DataCentersRequestImplTest { - @Test + @RetryingTest(3) public void test() { DataCentersResponse complete = client().dataCenters().complete(); Assertions.assertFalse(complete.dataCenters().isEmpty()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImplTest.java index 710a3010..a0515977 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/HistoryRequestImplTest.java @@ -17,32 +17,39 @@ import java.util.stream.Stream; import static de.chojo.universalis.rest.ClientWrapper.client; +import static de.chojo.universalis.rest.LiveApiRetry.retry; class HistoryRequestImplTest { @ParameterizedTest @MethodSource("inputs") void limit(HistoryRequest request) { - HistoryResponse complete = request - .limit(3) - .complete(); - Assertions.assertEquals(3, complete.sales().size()); + retry(() -> { + HistoryResponse complete = request + .limit(3) + .complete(); + Assertions.assertEquals(3, complete.sales().size()); + }); } @ParameterizedTest @MethodSource("inputs") void historyTime(HistoryRequest request) { - HistoryResponse noGst = request.historyTime(Duration.ofDays(7)).complete(); - HistoryResponse gst = request.historyTime(Duration.ofHours(1)).complete(); - Assertions.assertNotEquals(noGst.sales().size(), gst.sales().size()); + retry(() -> { + HistoryResponse noGst = request.historyTime(Duration.ofDays(7)).complete(); + HistoryResponse gst = request.historyTime(Duration.ofHours(1)).complete(); + Assertions.assertNotEquals(noGst.sales().size(), gst.sales().size()); + }); } @ParameterizedTest @MethodSource("inputs") void statsTime(HistoryRequest request) { - HistoryResponse noGst = request.statsTime(Duration.ofDays(7)).complete(); - HistoryResponse gst = request.statsTime(Duration.ofHours(1)).complete(); - Assertions.assertNotEquals(noGst.saleVelocity(), gst.saleVelocity()); + retry(() -> { + HistoryResponse noGst = request.statsTime(Duration.ofDays(7)).complete(); + HistoryResponse gst = request.statsTime(Duration.ofHours(1)).complete(); + Assertions.assertNotEquals(noGst.saleVelocity(), gst.saleVelocity()); + }); } diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImplTest.java index e8a9076a..af692ff5 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketBoardRequestImplTest.java @@ -18,67 +18,77 @@ import java.util.stream.Stream; import static de.chojo.universalis.rest.ClientWrapper.client; +import static de.chojo.universalis.rest.LiveApiRetry.retry; class MarketBoardRequestImplTest { @ParameterizedTest @MethodSource("inputs") void historyLimit(MarketBoardRequest request) { - MarketBoardResponse complete = request - .historyLimit(3) - .complete(); - Assertions.assertEquals(3, complete.recentHistory().size()); + retry(() -> { + MarketBoardResponse complete = request + .historyLimit(3) + .complete(); + Assertions.assertEquals(3, complete.recentHistory().size()); + }); } @ParameterizedTest @MethodSource("inputs") void listingLimit(MarketBoardRequest request) { - MarketBoardResponse complete = request - .listingsLimit(3) - .complete(); - Assertions.assertFalse(complete.listings().isEmpty()); - Assertions.assertTrue(complete.listings().size() <= 3); + retry(() -> { + MarketBoardResponse complete = request + .listingsLimit(3) + .complete(); + Assertions.assertFalse(complete.listings().isEmpty()); + Assertions.assertTrue(complete.listings().size() <= 3); + }); } @ParameterizedTest @MethodSource("inputs") void highQuality(MarketBoardRequest request) { - var complete = request.highQuality().complete(); - for (Listing listing : complete.listings()) { - Assertions.assertTrue(listing.meta().hq()); - } - + retry(() -> { + var complete = request.highQuality().complete(); + for (Listing listing : complete.listings()) { + Assertions.assertTrue(listing.meta().hq()); + } + }); } @ParameterizedTest @MethodSource("inputs") void normalQuality(MarketBoardRequest request) { - var complete = request.normalQuality().complete(); - for (Listing listing : complete.listings()) { - Assertions.assertFalse(listing.meta().hq()); - } - + retry(() -> { + var complete = request.normalQuality().complete(); + for (Listing listing : complete.listings()) { + Assertions.assertFalse(listing.meta().hq()); + } + }); } @ParameterizedTest @MethodSource("inputs") void historyTime(MarketBoardRequest request) { - request.historyLimit(100); - request.historyTime(Duration.ofDays(7)); - System.out.println(request); - MarketBoardResponse days = request.complete(); - request.historyTime(Duration.ofHours(1)); - System.out.println(request); - MarketBoardResponse hours = request.complete(); - Assertions.assertNotEquals(days.recentHistory().size(), hours.recentHistory().size(), "Received same results for %s and %s".formatted(days, hours)); + retry(() -> { + request.historyLimit(100); + request.historyTime(Duration.ofDays(7)); + MarketBoardResponse days = request.complete(); + request.historyTime(Duration.ofHours(1)); + MarketBoardResponse hours = request.complete(); + Assertions.assertNotEquals(days.recentHistory().size(), hours.recentHistory().size(), + "Received same results for %s and %s".formatted(days, hours)); + }); } @ParameterizedTest @MethodSource("inputs") void statsTime(MarketBoardRequest request) { - MarketBoardResponse days = request.statsTime(Duration.ofDays(7)).complete(); - MarketBoardResponse hours = request.statsTime(Duration.ofHours(1)).complete(); - Assertions.assertNotEquals(days.saleVelocity(), hours.saleVelocity()); + retry(() -> { + MarketBoardResponse days = request.statsTime(Duration.ofDays(7)).complete(); + MarketBoardResponse hours = request.statsTime(Duration.ofHours(1)).complete(); + Assertions.assertNotEquals(days.saleVelocity(), hours.saleVelocity()); + }); } public static Stream inputs() { diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketableRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketableRequestImplTest.java index f2a63d08..3cbfb466 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketableRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/MarketableRequestImplTest.java @@ -8,13 +8,13 @@ import de.chojo.universalis.rest.response.MarketableResponse; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; class MarketableRequestImplTest { - @Test + @RetryingTest(3) public void test() { MarketableResponse complete = client().marketable().complete(); Assertions.assertFalse(complete.items().isEmpty()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/TaxRatesRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/TaxRatesRequestImplTest.java index 8e748e4e..d1c671a5 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/TaxRatesRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/TaxRatesRequestImplTest.java @@ -8,15 +8,14 @@ import de.chojo.universalis.rest.response.TaxRatesResponse; import de.chojo.universalis.worlds.Worlds; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; import static org.junit.jupiter.api.Assertions.assertEquals; class TaxRatesRequestImplTest { - - @Test + @RetryingTest(3) public void test() { TaxRatesResponse complete = client().taxRates().world(Worlds.europe().light().odin).complete(); assertEquals(8, complete.rates().size()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/LeastRecentlyUpdatedRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/LeastRecentlyUpdatedRequestImplTest.java index f7097c1a..673181cb 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/LeastRecentlyUpdatedRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/LeastRecentlyUpdatedRequestImplTest.java @@ -16,6 +16,7 @@ import java.util.stream.Stream; import static de.chojo.universalis.rest.ClientWrapper.client; +import static de.chojo.universalis.rest.LiveApiRetry.retry; class LeastRecentlyUpdatedRequestImplTest { @@ -23,8 +24,10 @@ class LeastRecentlyUpdatedRequestImplTest { @ParameterizedTest @MethodSource("inputs") public void test(LeastRecentlyUpdatedRequest request) { - LeastRecentlyUpdatedResponse complete = request.complete(); - Assertions.assertFalse(complete.items().isEmpty()); + retry(() -> { + LeastRecentlyUpdatedResponse complete = request.complete(); + Assertions.assertFalse(complete.items().isEmpty()); + }); } public static Stream inputs() { diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/MostRecentlyUpdatedRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/MostRecentlyUpdatedRequestImplTest.java index d7fbe4fb..9355f6cf 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/MostRecentlyUpdatedRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/MostRecentlyUpdatedRequestImplTest.java @@ -14,6 +14,7 @@ import java.util.stream.Stream; import static de.chojo.universalis.rest.ClientWrapper.client; +import static de.chojo.universalis.rest.LiveApiRetry.retry; import static org.junit.jupiter.api.Assertions.assertFalse; class MostRecentlyUpdatedRequestImplTest { @@ -22,8 +23,10 @@ class MostRecentlyUpdatedRequestImplTest { @ParameterizedTest @MethodSource("inputs") public void test(MostRecentlyUpdatedRequest request) { - var complete = request.complete(); - assertFalse(complete.items().isEmpty()); + retry(() -> { + var complete = request.complete(); + assertFalse(complete.items().isEmpty()); + }); } public static Stream inputs() { diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploadHistoryRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploadHistoryRequestImplTest.java index 9d51d116..94d40033 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploadHistoryRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploadHistoryRequestImplTest.java @@ -7,15 +7,14 @@ package de.chojo.universalis.rest.routes.requests.extra.stats; import de.chojo.universalis.rest.response.extra.stats.UploadHistoryResponse; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; import static org.junit.jupiter.api.Assertions.assertFalse; class UploadHistoryRequestImplTest { - - @Test + @RetryingTest(3) public void test() { UploadHistoryResponse complete = client().extra().stats().uploadHistory().complete(); assertFalse(complete.uploadCountByDay().isEmpty()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploaderUploadCountsRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploaderUploadCountsRequestImplTest.java index 702bd636..93e4dba0 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploaderUploadCountsRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/UploaderUploadCountsRequestImplTest.java @@ -7,15 +7,14 @@ package de.chojo.universalis.rest.routes.requests.extra.stats; import de.chojo.universalis.rest.response.extra.stats.UploaderUploadCountResponse; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; import static org.junit.jupiter.api.Assertions.assertFalse; class UploaderUploadCountsRequestImplTest { - - @Test + @RetryingTest(3) public void test() { UploaderUploadCountResponse complete = client().extra().stats().uploaderUploadCounts().complete(); assertFalse(complete.uploader().isEmpty()); diff --git a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/WorldUploadCountsRequestImplTest.java b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/WorldUploadCountsRequestImplTest.java index 28b57dae..c2c4994b 100644 --- a/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/WorldUploadCountsRequestImplTest.java +++ b/universalis-rest/src/test/java/de/chojo/universalis/rest/routes/requests/extra/stats/WorldUploadCountsRequestImplTest.java @@ -8,14 +8,13 @@ import de.chojo.universalis.rest.response.extra.stats.WorldUploadCountResponse; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.RetryingTest; import static de.chojo.universalis.rest.ClientWrapper.client; class WorldUploadCountsRequestImplTest { - - @Test + @RetryingTest(3) public void test() { WorldUploadCountResponse complete = client().extra().stats().worldUploadCounts().complete(); Assertions.assertFalse(complete.worlds().isEmpty()); diff --git a/universalis-ws/build.gradle.kts b/universalis-ws/build.gradle.kts index 7e951386..c5ef8ca6 100644 --- a/universalis-ws/build.gradle.kts +++ b/universalis-ws/build.gradle.kts @@ -4,5 +4,5 @@ dependencies { // Websockets api(libs.websocketclient) api(libs.mongodb.bson) - api(libs.google.guava) + api(libs.caffeine) } diff --git a/universalis-ws/src/main/java/de/chojo/universalis/websocket/UniversalisWsImpl.java b/universalis-ws/src/main/java/de/chojo/universalis/websocket/UniversalisWsImpl.java index 4f9fb07f..117482b0 100644 --- a/universalis-ws/src/main/java/de/chojo/universalis/websocket/UniversalisWsImpl.java +++ b/universalis-ws/src/main/java/de/chojo/universalis/websocket/UniversalisWsImpl.java @@ -14,14 +14,16 @@ import de.chojo.universalis.websocket.listener.StatusListener; import de.chojo.universalis.websocket.listener.WebsocketListenerAdapter; import de.chojo.universalis.websocket.subscriber.Subscription; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; -import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.time.Duration; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import static org.slf4j.LoggerFactory.getLogger; @@ -30,15 +32,17 @@ */ public class UniversalisWsImpl implements UniversalisWs { private static final Logger log = getLogger(UniversalisWsImpl.class); - private static final String WEBSOCKET_URL = "wss://universalis.app/api/ws"; + private static final Duration RECONNECT_DELAY = Duration.ofSeconds(5); + private final String websocketUrl; private final WebSocketFactory factory; private final ExecutorService websocketWorker; private final List subscribers; private final List listeners; private final NameSupplier itemNameSupplier; - private WebSocket socket; - private StatusListener statusListener; - private boolean active = true; + private final AtomicBoolean reconnectInFlight = new AtomicBoolean(); + private volatile WebSocket socket; + private volatile StatusListener statusListener; + private volatile boolean active = true; /** * Creates an universalis websocket implementation. @@ -48,13 +52,15 @@ public class UniversalisWsImpl implements UniversalisWs { * @param subscribers subscriptions * @param listeners listeners * @param itemNameSupplier item name supplier + * @param websocketUrl websocket url */ - public UniversalisWsImpl(WebSocketFactory factory, ExecutorService websocketWorker, List subscribers, List listeners, NameSupplier itemNameSupplier) { + public UniversalisWsImpl(WebSocketFactory factory, ExecutorService websocketWorker, List subscribers, List listeners, NameSupplier itemNameSupplier, String websocketUrl) { this.factory = factory; this.websocketWorker = websocketWorker; this.subscribers = subscribers; this.listeners = listeners; this.itemNameSupplier = itemNameSupplier; + this.websocketUrl = websocketUrl; } @@ -62,68 +68,93 @@ public UniversalisWsImpl(WebSocketFactory factory, ExecutorService websocketWork * Attempts to create the socket and connect it */ public void ignite() { + if (!active) return; try { internalIgnite(); } catch (Throwable e) { - log.error("Failed to create a socket. Trying again in 5 seconds.", e); - CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(this::ignite); + scheduleReconnect("Failed to create a socket", e); } } private void internalIgnite() { if (!active) return; - if (socket != null) { - log.info("Found old socket. Checking"); - if (socket.isOpen()) { - log.info("Socket is open. Closing"); - socket.disconnect(0); + WebSocket previous = socket; + if (previous != null) { + log.info("Closing previous socket"); + previous.clearListeners(); + if (previous.isOpen()) { + previous.disconnect(0); } - log.info("Trying to reconnect"); } + WebSocket newSocket; try { log.info("Creating a new socket"); - socket = factory.createSocket(WEBSOCKET_URL, 10000); + newSocket = factory.createSocket(websocketUrl, 10000); } catch (IOException e) { - log.error("Failed to create a socket. Trying again in 5 seconds.", e); - CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(this::ignite); + scheduleReconnect("Failed to create a socket", e); return; } - socket.setPingInterval(5); + newSocket.setPingInterval(5); + newSocket.addListener(new WebsocketListenerAdapter(listeners, itemNameSupplier)); + StatusListener listener = new StatusListener(this, subscribers); + newSocket.addListener(listener); - socket.addListener(new WebsocketListenerAdapter(listeners, itemNameSupplier)); - statusListener = new StatusListener(this, subscribers); - socket.addListener(statusListener); + socket = newSocket; + statusListener = listener; CompletableFuture.runAsync(() -> { try { log.info("Attempting to establish socket connection."); - socket.connect(); + newSocket.connect(); } catch (WebSocketException e) { - log.error("Failed to create a connection. Trying again in 5 seconds", e); - CompletableFuture.delayedExecutor(5, TimeUnit.SECONDS).execute(this::ignite); + scheduleReconnect("Failed to establish socket connection", e); } }, websocketWorker); + } + private void scheduleReconnect(String reason, Throwable cause) { + if (!active) return; + if (!reconnectInFlight.compareAndSet(false, true)) { + log.debug("Reconnect already in flight, skipping ({})", reason); + return; + } + log.error("{}. Trying again in {}s.", reason, RECONNECT_DELAY.toSeconds(), cause); + CompletableFuture.delayedExecutor(RECONNECT_DELAY.toSeconds(), TimeUnit.SECONDS).execute(() -> { + reconnectInFlight.set(false); + ignite(); + }); } @Override public void subscribe(Subscription subscription) { - statusListener.subscribe(subscription); + StatusListener listener = statusListener; + if (listener == null) { + throw new IllegalStateException("Websocket has not been ignited yet."); + } + listener.subscribe(subscription); } @Override public void unsubscribe(Subscription subscription) { - statusListener.unsubscribe(subscription); + StatusListener listener = statusListener; + if (listener == null) { + throw new IllegalStateException("Websocket has not been ignited yet."); + } + listener.unsubscribe(subscription); } @Override public void disconnect() { active = false; log.info("Attempting to disconnect socket"); - socket.disconnect(0); + WebSocket current = socket; + if (current != null) { + current.clearListeners(); + current.disconnect(0); + } log.info("Socket disconnected"); } @@ -139,7 +170,8 @@ public WebSocket socket() { @Override public void awaitReady() { - while (!statusListener.isConnected()) { + StatusListener listener; + while ((listener = statusListener) == null || !listener.isConnected()) { Thread.onSpinWait(); } } diff --git a/universalis-ws/src/main/java/de/chojo/universalis/websocket/builder/UniversalisWsBuilder.java b/universalis-ws/src/main/java/de/chojo/universalis/websocket/builder/UniversalisWsBuilder.java index dcf455d8..ff4f1c2c 100644 --- a/universalis-ws/src/main/java/de/chojo/universalis/websocket/builder/UniversalisWsBuilder.java +++ b/universalis-ws/src/main/java/de/chojo/universalis/websocket/builder/UniversalisWsBuilder.java @@ -24,11 +24,21 @@ * Builder to create a {@link UniversalisWs} instance. */ public class UniversalisWsBuilder { + /** + * Default websocket URL for the universalis service. + */ + public static final String DEFAULT_WEBSOCKET_URL = "wss://universalis.app/api/ws"; + private final WebSocketFactory factory; private final List subscriptions = new ArrayList<>(); private final List listeners = new ArrayList<>(); - private ExecutorService executorService = Executors.newCachedThreadPool(); + private ExecutorService executorService = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "universalis-ws-worker"); + t.setDaemon(true); + return t; + }); private NameSupplier nameSupplier = NameSupplier.EMPTY; + private String websocketUrl = DEFAULT_WEBSOCKET_URL; /** * Create a new universalis websocket builder @@ -82,6 +92,17 @@ public UniversalisWsBuilder itemNameSupplier(NameSupplier nameSupplier) { return this; } + /** + * Override the websocket URL. Defaults to {@link #DEFAULT_WEBSOCKET_URL}. + * + * @param websocketUrl websocket url + * @return builder instance + */ + public UniversalisWsBuilder websocketUrl(String websocketUrl) { + this.websocketUrl = websocketUrl; + return this; + } + /** * Builds and attempts to connect the websocket. *

@@ -90,7 +111,7 @@ public UniversalisWsBuilder itemNameSupplier(NameSupplier nameSupplier) { * @return Universalis websocket instance */ public UniversalisWs build() { - UniversalisWsImpl universalisWs = new UniversalisWsImpl(factory, executorService, subscriptions, listeners, nameSupplier); + UniversalisWsImpl universalisWs = new UniversalisWsImpl(factory, executorService, subscriptions, listeners, nameSupplier, websocketUrl); universalisWs.ignite(); return universalisWs; } diff --git a/universalis-ws/src/main/java/de/chojo/universalis/websocket/events/concrete/sales/views/SaleView.java b/universalis-ws/src/main/java/de/chojo/universalis/websocket/events/concrete/sales/views/SaleView.java index a907ab42..35c8f609 100644 --- a/universalis-ws/src/main/java/de/chojo/universalis/websocket/events/concrete/sales/views/SaleView.java +++ b/universalis-ws/src/main/java/de/chojo/universalis/websocket/events/concrete/sales/views/SaleView.java @@ -7,7 +7,7 @@ package de.chojo.universalis.websocket.events.concrete.sales.views; import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonDeserialize; import de.chojo.universalis.deserializer.SecondsDateTimeConverter; import de.chojo.universalis.entities.Price; import de.chojo.universalis.entities.Sale; diff --git a/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/StatusListener.java b/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/StatusListener.java index b3d40a39..e0928360 100644 --- a/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/StatusListener.java +++ b/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/StatusListener.java @@ -30,7 +30,7 @@ public class StatusListener extends WebSocketAdapter { private final BSONEncoder encoder = new BasicBSONEncoder(); private final UniversalisWsImpl universalisWs; private final List subscriptions; - private boolean connected; + private volatile boolean connected; /** * Creates a new status listener diff --git a/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/WebsocketListenerAdapter.java b/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/WebsocketListenerAdapter.java index 4584ee5e..8de08400 100644 --- a/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/WebsocketListenerAdapter.java +++ b/universalis-ws/src/main/java/de/chojo/universalis/websocket/listener/WebsocketListenerAdapter.java @@ -6,13 +6,13 @@ package de.chojo.universalis.websocket.listener; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.json.JsonMapper; -import com.fasterxml.jackson.databind.module.SimpleModule; -import com.google.common.cache.Cache; -import com.google.common.cache.CacheBuilder; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import com.neovisionaries.ws.client.WebSocket; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; import com.neovisionaries.ws.client.WebSocketAdapter; import com.neovisionaries.ws.client.WebSocketException; import de.chojo.universalis.deserializer.CityDeserializer; @@ -53,9 +53,9 @@ public class WebsocketListenerAdapter extends WebSocketAdapter implements EventL private final BSONDecoder decoder = new BasicBSONDecoder(); private final List listeners; private final ObjectMapper objectMapper; - private final Cache removedListings = CacheBuilder.newBuilder() - .expireAfterWrite(Duration.ofSeconds(10)) - .build(); + private final Cache removedListings = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(10)) + .build(); /** * Creates a new websocket listener adapter @@ -68,10 +68,11 @@ public WebsocketListenerAdapter(List listeners, NameSupplier item SimpleModule module = new SimpleModule(); module.addDeserializer(World.class, new WorldDeserializer()) .addDeserializer(Item.class, new ItemDeserializer(itemNameSupplier)) -// .addDeserializer(Instant.class, new SecondsDateTimeConverter()) .addDeserializer(City.class, new CityDeserializer()); - objectMapper = new JsonMapper().registerModule(module) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper = JsonMapper.builder() + .addModule(module) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .build(); } @Override @@ -80,6 +81,10 @@ public void onBinaryMessage(WebSocket websocket, byte[] binary) throws Exception String event = (String) map.remove("event"); log.trace("Received event {}", event); log.trace("{}", objectMapper.writeValueAsString(map)); + if (event == null) { + log.warn("Received binary message with no 'event' field: {}", map); + return; + } switch (event) { case "sales/add" -> onSalesAdd(mapToEvent(map, WsSalesAddEvent.class)); case "sales/remove" -> onSalesRemove(mapToEvent(map, WsSalesRemoveEvent.class)); @@ -93,6 +98,7 @@ public void onBinaryMessage(WebSocket websocket, byte[] binary) throws Exception onListingRemove(wsRemove.toEvent()); removedListings.put(wsRemove.world(), wsRemove); } + default -> log.warn("Received unknown event '{}' with payload {}", event, map); } }