Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
name: Build

on:
push:

jobs:
build:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Environment Variables
run: echo "VERSION=${GITHUB_SHA}" >> $GITHUB_ENV

- name: Build with Gradle Cache
uses: burrunan/gradle-cache-action@v3
with:
gradle-version: '9.4.0'
arguments: build
env:
REPO_URL: ${{ secrets.MAVEN_URL }}
REPO_USER: ${{ secrets.MAVEN_USERNAME }}
REPO_PASS: ${{ secrets.MAVEN_PASSWORD }}
44 changes: 44 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Build and Release

on:
push:
tags:
- "*"

jobs:
build:

runs-on: ubuntu-latest
permissions:
contents: write

steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'

- name: Setup Environment Variables
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV

- name: Build with Gradle Cache
uses: burrunan/gradle-cache-action@v3
with:
gradle-version: '9.4.0'
arguments: |
build
publish
env:
REPO_URL: ${{ secrets.MAVEN_URL }}
REPO_USER: ${{ secrets.MAVEN_USERNAME }}
REPO_PASS: ${{ secrets.MAVEN_PASSWORD }}

- name: Publish GitHub Release
id: github_release
uses: softprops/action-gh-release@v2
with:
files: build/SDK-${GITHUB_REF#refs/tags/}-*.jar
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.idea/
.gradle/
build/
/gradle.properties
12 changes: 5 additions & 7 deletions build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,30 +1,27 @@
import org.gradle.internal.serialize.graph.codec
import java.net.URI

plugins {
`java-library`
`maven-publish`

alias(libs.plugins.shadow)
alias(libs.plugins.lombok)
}

group = "gg.hoglin"
version = "1.2.1"
version = System.getenv("VERSION") ?: "dev"

repositories {
mavenCentral()
}

dependencies {
implementation(libs.gson)
implementation(libs.unirest.core)
implementation(libs.unirest.gson)
implementation(libs.unirest.jackson)
implementation(libs.slf4j)
implementation(libs.commons.codec)
compileOnly(libs.lombok)
compileOnly(libs.jetbrains.annotations)
compileOnly(libs.autoservice)
annotationProcessor(libs.lombok)
annotationProcessor(libs.autoservice)
}

Expand All @@ -36,6 +33,7 @@ java {
tasks.javadoc {
val options = options as StandardJavadocDocletOptions
options.addStringOption("tag", "apiNote:a:API Note:")
options.addStringOption("Xdoclint:none", "-quiet") // to really suppress all the doc errors

// Needed to suppress errors caused by Lombok autogenerated members
isFailOnError = false
Expand All @@ -60,7 +58,7 @@ publishing {
repositories {
maven {
name = "Hoglin"
url = URI("https://maven.hoglin.gg/releases")
url = URI(findProperty("repo.waypoint.url") as String? ?: System.getenv("REPO_URL"))
credentials {
username = findProperty("repo.waypoint.username") as String? ?: System.getenv("REPO_USER")
password = findProperty("repo.waypoint.password") as String? ?: System.getenv("REPO_PASS")
Expand Down
7 changes: 5 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
[versions]
shadow = "8.3.0"
lombok = "1.18.38"
lombok = "9.5.0"
gson = "2.13.1"
unirest = "4.4.5"
jetbrains-annotations = "24.0.0"
autoservice = "1.1.1"
slf4j = "2.0.17"
commons-codec = "1.20.0"
jackson = "3.1.2"

[plugins]
shadow = { id = "com.gradleup.shadow", version.ref = "shadow" }
lombok = { id = "io.freefair.lombok", version.ref = "lombok" }

[libraries]
lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
unirest-core = { module = "com.konghq:unirest-java-core", version.ref = "unirest" }
unirest-gson = { module = "com.konghq:unirest-modules-gson", version.ref = "unirest" }
unirest-jackson = { module = "com.konghq:unirest-modules-jackson", version.ref = "unirest" }
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
autoservice = { module = "com.google.auto.service:auto-service", version.ref = "autoservice" }
slf4j = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
commons-codec = { module = "commons-codec:commons-codec", version.ref = "commons-codec" }
jackson = { module = "tools.jackson.core:jackson-databind", version.ref = "jackson" }
107 changes: 83 additions & 24 deletions src/main/java/gg/hoglin/sdk/Hoglin.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package gg.hoglin.sdk;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.core.JacksonException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import gg.hoglin.sdk.models.analytic.Analytic;
import gg.hoglin.sdk.models.analytic.NamedAnalytic;
import gg.hoglin.sdk.models.analytic.RecordedAnalytic;
Expand All @@ -11,7 +16,8 @@
import gg.hoglin.sdk.models.experiment.ExperimentEvaluationResponse;
import gg.hoglin.sdk.models.visualization.ImportedSnapshotEvaluation;
import gg.hoglin.sdk.models.visualization.SnapshotImport;
import gg.hoglin.sdk.serialization.HoglinAdapter;
import gg.hoglin.sdk.serialization.InstantDeserializer;
import gg.hoglin.sdk.serialization.InstantSerializer;
import gg.hoglin.sdk.strategy.HoglinRetryStrategy;
import gg.hoglin.sdk.task.AnalyticBatchTask;
import gg.hoglin.sdk.task.ExperimentFetchTask;
Expand Down Expand Up @@ -115,10 +121,10 @@ public class Hoglin implements Closeable {
private UnirestInstance httpClient;

/**
* The Gson instance used for serialization and deserialization
* The Jackson instance used for serialization and deserialization
*/
@ToString.Exclude
@Builder.Default @NotNull private Gson gson = createDefaultGson();
@Builder.Default @NotNull private ObjectMapper objectMapper = createDefaultObjectMapper();

/**
* The event queue for storing recorded analytics before they are sent to the Hoglin API
Expand Down Expand Up @@ -202,7 +208,13 @@ public void refreshExperimentCache() {
logger.error("Failed to refresh experiment cache: {}", response.getBody());
}

final ExperimentData[] experiments = gson.fromJson(response.getBody(), ExperimentData[].class);
final ExperimentData[] experiments;
try {
experiments = objectMapper.readValue(response.getBody(), ExperimentData[].class);
} catch (JsonProcessingException e) {
logger.error("Failed to deserialize experiment payload: {}", response.getBody(), e);
return;
}
experimentCache.clear();

for (final ExperimentData experiment : experiments) {
Expand All @@ -226,9 +238,19 @@ public Map<String, ExperimentData> getExperiments() {
final ArrayList<RecordedAnalytic<?>> events = new ArrayList<>(eventQueue);
eventQueue.clear();

final HttpResponse<String> response = httpClient.put("/analytics/" + serverKey)
.body(gson.toJson(events))
.asString();
final HttpResponse<String> response;
try {
response = httpClient.put("/analytics/" + serverKey)
.body(objectMapper.writeValueAsBytes(events))
.asString();
} catch (JsonProcessingException e) {
// If you're a developer making custom events, and this gets thrown, it means your custom tracked analytics are invalid.
// Chances are, you may be using types that don't have built in serialization,
// and if that is the case, just write a custom serializer and register it with the Jackson ObjectMapper instance under the Hoglin loader.
// Thank you for your attention to this matter :)
logger.error("Failed to serialize tracked events", e);
return null;
}

if (requeueFailedFlushes && !response.isSuccess()) {
trackMany(events);
Expand Down Expand Up @@ -328,8 +350,14 @@ public HttpResponse<String> importSnapshot(final UUID snapshotId, @Nullable fina
throw new IllegalStateException("Attempted to import visualization snapshot whilst closed");
}

final RequestBodyEntity request = httpClient.post("/visualizations/" + serverKey + "/import")
.body(gson.toJson(new SnapshotImport(snapshotId, name, preventDuplicate)));
final RequestBodyEntity request;
try {
request = httpClient.post("/visualizations/" + serverKey + "/import")
.body(objectMapper.writeValueAsBytes(new SnapshotImport(snapshotId, name, preventDuplicate)));
} catch (JsonProcessingException e) {
logger.error("Failed to serialize snapshot", e);
throw new RuntimeException("Snapshot failed to serialize, this should never happen, so there is something very wrong in the code");
}

return request.asString();
}
Expand Down Expand Up @@ -419,7 +447,12 @@ public boolean isSnapshotImported(final UUID snapshotId) {
return new ImportedSnapshotEvaluation(false, null);
}

return gson.fromJson(response.getBody(), ImportedSnapshotEvaluation.class);
try {
return objectMapper.readValue(response.getBody(), ImportedSnapshotEvaluation.class);
} catch (JsonProcessingException e) {
logger.error("Failed to deserialize snapshot payload: {}", response.getBody(), e);
throw new RuntimeException(e);
}
}

/**
Expand Down Expand Up @@ -496,7 +529,14 @@ public boolean evaluateExperiment(final String experimentId, @NotNull final UUID
logger.error("Failed to evaluate experiment {} for player {}: {}", experimentId, playerUUID, constructErrorDescription(response));
return false;
}
ExperimentEvaluationResponse expEvalResp = gson.fromJson(response.getBody(), ExperimentEvaluationResponse.class);

ExperimentEvaluationResponse expEvalResp;
try {
expEvalResp = objectMapper.readValue(response.getBody(), ExperimentEvaluationResponse.class);
} catch (JsonProcessingException e) {
logger.error("Failed to deserialize payload for experiment {} for player {}: {}", experimentId, experimentId, response.getBody(), e);
return false;
}

// Add to cache
Map<String, Boolean> map = participationCache.computeIfAbsent(playerUUID, k -> new ConcurrentHashMap<>());
Expand Down Expand Up @@ -555,23 +595,24 @@ public HttpResponse<String> evaluateExperimentRaw(final String experimentId, fin
public String constructErrorDescription(final HttpResponse<String> response) {
final String httpStatus = "(HTTP " + response.getStatus()+ "): ";
try {
final ApiErrorResponse error = gson.fromJson(response.getBody(), ApiErrorResponse.class);
final ApiErrorResponse error = objectMapper.readValue(response.getBody(), ApiErrorResponse.class);
return httpStatus + error.parsedDescription();
} catch (final JsonSyntaxException e) {
} catch (final JacksonException e) {
return httpStatus + "Received unstructured error response: " + response.getBody();
} catch (final Exception e) {
return httpStatus + "An unexpected error occurred while processing the response: " + response.getBody() + " e: " + e.getMessage();
}
}

@SuppressWarnings("rawtypes")
private static Gson createDefaultGson() {
final GsonBuilder builder = new GsonBuilder();
final ServiceLoader<HoglinAdapter> adapters = ServiceLoader.load(HoglinAdapter.class, Hoglin.class.getClassLoader());
for (final HoglinAdapter adapter : adapters) {
builder.registerTypeAdapter(adapter.getType(), adapter);
}
return builder.create();
private static ObjectMapper createDefaultObjectMapper() {
ObjectMapper objectMapper = new ObjectMapper().setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY);

SimpleModule module = new SimpleModule();
module.addSerializer(Instant.class, new InstantSerializer());
module.addDeserializer(Instant.class, new InstantDeserializer());
objectMapper.registerModule(module);

return objectMapper;
}

private static UnirestInstance createDefaultHttpClient(final String baseUrl) {
Expand Down Expand Up @@ -627,6 +668,24 @@ private HoglinBuilder closed(final boolean closed) {
private HoglinBuilder autoFlushTask(final ScheduledFuture<?> autoFlushTask) {
return this;
}

/**
* Helper for adding custom serializers to the Jackson instance.
* The idea is that people are incentivized to not use a custom Jackson instance.
*
* @param serializer Object serializer
* @param deserializer Object deserializer
* @return This instance
* @param <T> Class type
*/
public <T> HoglinBuilder addCustomSerializer(JsonSerializer<T> serializer, JsonDeserializer<T> deserializer) {
SimpleModule module = new SimpleModule();
module.addSerializer(serializer);
module.addDeserializer(serializer.handledType(), deserializer);

this.objectMapper$value.registerModule(module);
return this;
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gg.hoglin.sdk.models.analytic;

import com.google.gson.annotations.SerializedName;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import lombok.experimental.Accessors;

Expand All @@ -13,7 +14,7 @@
@Data
@Accessors(fluent = true)
public class RecordedAnalytic<T> {
@SerializedName("event_type") private final String eventType;
@JsonProperty("event_type") private final String eventType;
private final Instant timestamp;
private final T properties;
}
Loading
Loading