Migrate from Gson and org.json to Jackson

Replaced Gson and org.json usage across the codebase with a unified Jackson-based implementation through a shared `Json` utility. Updated all JSON serialization and deserialization logic (`GsonJsonMapper`, HTTP client, task persistence, command handling, tests) to use Jackson's `ObjectMapper`. Removed the outdated dependencies from `pom.xml` and verified symmetry in payload serialization.
This commit is contained in:
anthorob committed 2026-09-25 08:18:43 -04:00
1 parent 05b4a2ab85
commit 62de32ecfc
18 files changed
+225 -113

No files matched your search

+5 -5
View File
@@ -217,7 +217,7 @@ graph LR
# Delivery Steps # Delivery Steps
### * Step 1: Introduce Jackson and the shared Json utility ### ✓ Step 1: Introduce Jackson and the shared Json utility
Jackson is an explicit dependency and a single shared `ObjectMapper` is available to the codebase. Jackson is an explicit dependency and a single shared `ObjectMapper` is available to the codebase.
- Add `com.fasterxml.jackson.core:jackson-databind` to `pom.xml`, pinned to the version already resolved via JDA (2.21.2), next to the existing `org.json`/`gson` entries (which stay for now so the build keeps compiling). - Add `com.fasterxml.jackson.core:jackson-databind` to `pom.xml`, pinned to the version already resolved via JDA (2.21.2), next to the existing `org.json`/`gson` entries (which stay for now so the build keeps compiling).
@@ -225,14 +225,14 @@ Jackson is an explicit dependency and a single shared `ObjectMapper` is availabl
- Have `readObject`/`readArray` wrap Jackson's checked `JsonProcessingException` in an unchecked exception so existing call-site signatures stay unchanged. - Have `readObject`/`readArray` wrap Jackson's checked `JsonProcessingException` in an unchecked exception so existing call-site signatures stay unchanged.
- Verify no shade-plugin relocation or filter rules in `pom.xml` reference `org.json` or `gson`. - Verify no shade-plugin relocation or filter rules in `pom.xml` reference `org.json` or `gson`.
### Step 2: Migrate the Gson call sites to Jackson ### ✓ Step 2: Migrate the Gson call sites to Jackson
Gson is no longer referenced anywhere in the source tree. Gson is no longer referenced anywhere in the source tree.
- Delete `http/server/GsonJsonMapper.java` and remove its registration from the Javalin configuration, relying on Javalin 7's built-in Jackson `JsonMapper` instead. - Delete `http/server/GsonJsonMapper.java` and remove its registration from the Javalin configuration, relying on Javalin 7's built-in Jackson `JsonMapper` instead.
- Replace `new Gson().toJson(params)` at `http/WebModule.java:256` with `Json.write(params)`, reusing the shared mapper instead of allocating per request. - Replace `new Gson().toJson(params)` at `http/WebModule.java:256` with `Json.write(params)`, reusing the shared mapper instead of allocating per request.
- Confirm Javalin request/response serialization still behaves identically for the existing endpoints. - Confirm Javalin request/response serialization still behaves identically for the existing endpoints.
### Step 3: Migrate the HTTP client and its consumers ### ✓ Step 3: Migrate the HTTP client and its consumers
`WebModule` returns Jackson nodes and its callers read them. `WebModule` returns Jackson nodes and its callers read them.
- Change `WebModule.getAsObject()` to return `ObjectNode` and `getAsArray()` to return `ArrayNode` (lines 133-143), parsing through `Json`. - Change `WebModule.getAsObject()` to return `ObjectNode` and `getAsArray()` to return `ArrayNode` (lines 133-143), parsing through `Json`.
@@ -240,7 +240,7 @@ Gson is no longer referenced anywhere in the source tree.
- Update `command/crypto/CryptoPriceCommand.java` to read the crypto API response via `JsonNode` accessors. - Update `command/crypto/CryptoPriceCommand.java` to read the crypto API response via `JsonNode` accessors.
- Remove the now-unused `org.json` imports from these three files. - Remove the now-unused `org.json` imports from these three files.
### Step 4: Migrate the task persistence layer ### ✓ Step 4: Migrate the task persistence layer
Tasks serialize and restore through Jackson while keeping the existing on-disk payload shape. Tasks serialize and restore through Jackson while keeping the existing on-disk payload shape.
- Change `PersistableTask.restore(JSONObject)` to `restore(ObjectNode)`. - Change `PersistableTask.restore(JSONObject)` to `restore(ObjectNode)`.
@@ -249,7 +249,7 @@ Tasks serialize and restore through Jackson while keeping the existing on-disk p
- Update `PersistableTaskRegistry` and `db/service/TaskService` to parse stored payload strings via `Json.readObject`. - Update `PersistableTaskRegistry` and `db/service/TaskService` to parse stored payload strings via `Json.readObject`.
- Confirm `tasks/reaction/EmojiReactionRoleTask` still compiles unchanged against the new `EmojiTask` contract. - Confirm `tasks/reaction/EmojiReactionRoleTask` still compiles unchanged against the new `EmojiTask` contract.
### Step 5: Update tests and drop the old dependencies ### ✓ Step 5: Update tests and drop the old dependencies
`org.json` and Gson are removed from `pom.xml` and the full build passes. `org.json` and Gson are removed from `pom.xml` and the full build passes.
- Rewrite payload construction in `PersistableTaskRegistryTest` and `EmojiReactionRoleTaskPersistenceTest` using `ObjectNode`. - Rewrite payload construction in `PersistableTaskRegistryTest` and `EmojiReactionRoleTaskPersistenceTest` using `ObjectNode`.
+3 -8
View File
@@ -217,14 +217,9 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>org.json</groupId> <groupId>com.fasterxml.jackson.core</groupId>
<artifactId>json</artifactId> <artifactId>jackson-databind</artifactId>
<version>20250517</version> <version>2.21.2</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.14.0</version>
</dependency> </dependency>
<dependency> <dependency>
@@ -5,13 +5,14 @@ package me.bobert.netneutrality.bootstrap;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.music.MusicService; import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.MusicManager; import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.TrackScheduler; import me.bobert.netneutrality.music.TrackScheduler;
import org.json.JSONArray; import me.bobert.netneutrality.utils.Json;
import org.json.JSONObject;
import java.io.File; import java.io.File;
import java.io.FileWriter; import java.io.FileWriter;
@@ -43,8 +44,8 @@ public class BotState {
Map<String, GuildService> services = guildServices.getServices(); Map<String, GuildService> services = guildServices.getServices();
JSONObject main = new JSONObject(); ObjectNode main = Json.object();
JSONArray guildsArray = new JSONArray(); ArrayNode guildsArray = Json.array();
for (MusicManager manager : musicService.getManagers()) { for (MusicManager manager : musicService.getManagers()) {
TrackScheduler trackScheduler = manager.getScheduler(); TrackScheduler trackScheduler = manager.getScheduler();
@@ -53,8 +54,8 @@ public class BotState {
for (GuildService service : services.values()) { for (GuildService service : services.values()) {
JSONObject guild = new JSONObject(); ObjectNode guild = Json.object();
JSONObject music = new JSONObject(); ObjectNode music = Json.object();
guild.put("id", service.getEntity().getGuildId()); guild.put("id", service.getEntity().getGuildId());
// MessageChannel currentChannel = trackScheduler.getTextChannel(); // MessageChannel currentChannel = trackScheduler.getTextChannel();
@@ -64,12 +65,12 @@ public class BotState {
// music.put("current_channel", currentChannel.getId()); // music.put("current_channel", currentChannel.getId());
// } // }
guild.put("music", music); guild.set("music", music);
guildsArray.put(guild); guildsArray.add(guild);
} }
main.put("guilds", guildsArray); main.set("guilds", guildsArray);
File state = new File("state.json"); File state = new File("state.json");
try { try {
@@ -5,6 +5,8 @@ package me.bobert.netneutrality.command.crypto;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.LogHandler; import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandCategory; import me.bobert.netneutrality.core.command.CommandCategory;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
@@ -15,7 +17,6 @@ import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.interactions.commands.OptionType; import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.OptionData; import net.dv8tion.jda.api.interactions.commands.build.OptionData;
import org.json.JSONObject;
import java.text.NumberFormat; import java.text.NumberFormat;
import java.util.Currency; import java.util.Currency;
@@ -91,14 +92,19 @@ public class CryptoPriceCommand extends AbstractCommand {
} }
WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request(); WebModule http = new WebModule.Builder(String.format(baseURL, symbol, finalCurrency)).cache(30, TimeUnit.MINUTES).build().request();
JSONObject json = http.getAsObject(); ObjectNode json = http.getAsObject();
if (json.isEmpty() || (json.has("Response") && json.getString("Response").equalsIgnoreCase("Error"))) { if (json.isEmpty() || json.path("Response").asText("").equalsIgnoreCase("Error")) {
asyncMessage.edit("Crypto doesn't exist.. yet"); asyncMessage.edit("Crypto doesn't exist.. yet");
return; return;
} }
double value = json.getDouble(finalCurrency); JsonNode priceNode = json.path(finalCurrency);
if (!priceNode.isNumber()) {
throw new IllegalStateException("Missing price for currency " + finalCurrency);
}
double value = priceNode.asDouble();
String price = dollarFormat.format(value); String price = dollarFormat.format(value);
asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price)); asyncMessage.edit(String.format("The current price of %s in %s is %s", symbol, finalCurrency, price));
@@ -115,12 +121,12 @@ public class CryptoPriceCommand extends AbstractCommand {
private boolean validateSymbol(String symbol) { private boolean validateSymbol(String symbol) {
try { try {
WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request(); WebModule http = new WebModule.Builder(exchangeApi).get().cache(1, TimeUnit.DAYS).build().request();
JSONObject json = http.getAsObject(); ObjectNode json = http.getAsObject();
JSONObject pairs = json.getJSONObject("Data") JsonNode pairs = json.path("Data")
.getJSONObject("exchanges") .path("exchanges")
.getJSONObject(exchange) .path(exchange)
.getJSONObject("pairs"); .path("pairs");
return pairs.has(symbol); return pairs.has(symbol);
@@ -5,8 +5,8 @@ package me.bobert.netneutrality.core.tasks;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import org.json.JSONObject;
/** /**
* Represents a task that can be persisted and restored later. * Represents a task that can be persisted and restored later.
@@ -29,7 +29,7 @@ public interface PersistableTask extends Task {
* *
* @param payload the persisted payload, must not be {@code null} * @param payload the persisted payload, must not be {@code null}
*/ */
void restore(JSONObject payload); void restore(ObjectNode payload);
void insert(CommandContext context); void insert(CommandContext context);
@@ -5,10 +5,11 @@ package me.bobert.netneutrality.core.tasks.impl;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.PersistableTask; import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import org.json.JSONObject; import me.bobert.netneutrality.utils.Json;
public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask { public abstract class AbstractPersistableTask extends AbstractTask implements PersistableTask {
@@ -66,15 +67,27 @@ public abstract class AbstractPersistableTask extends AbstractTask implements Pe
* @param payload the persisted payload * @param payload the persisted payload
*/ */
@Override @Override
public void restore(JSONObject payload) { public void restore(ObjectNode payload) {
// Nothing to restore by default // Nothing to restore by default
} }
/**
* No-op default: only tasks carrying extra state need to override this.
*
* @param payload the payload being written
*/
protected void writePayload(ObjectNode payload) {
// Nothing extra to write by default
}
@Override @Override
public String toPayload() { public String toPayload() {
JSONObject payload = new JSONObject(this); ObjectNode payload = Json.object();
payload.put("id", getId()); payload.put("id", getId());
payload.put("lockId", getLockId()); payload.put("lockId", getLockId());
payload.put("name", getName());
writePayload(payload);
return payload.toString(); return payload.toString();
} }
@@ -5,9 +5,10 @@ package me.bobert.netneutrality.core.tasks.impl;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.context.tasks.EmojiTaskContext; import me.bobert.netneutrality.context.tasks.EmojiTaskContext;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import org.json.JSONObject;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
@@ -27,7 +28,7 @@ public abstract class EmojiTask extends AbstractPersistableTask {
/** /**
* Constructor used by the persistable task registry: the message coordinates and the emoji * Constructor used by the persistable task registry: the message coordinates and the emoji
* mapping are hydrated afterwards by {@link #restore(JSONObject)}. * mapping are hydrated afterwards by {@link #restore(ObjectNode)}.
* *
* @param taskManager the application task manager, must not be {@code null} * @param taskManager the application task manager, must not be {@code null}
*/ */
@@ -36,18 +37,32 @@ public abstract class EmojiTask extends AbstractPersistableTask {
} }
@Override @Override
public void restore(JSONObject payload) { public void restore(ObjectNode payload) {
this.guildId = payload.optString("guildId", null); this.guildId = payload.path("guildId").asText(null);
this.channelId = payload.optString("channelId", null); this.channelId = payload.path("channelId").asText(null);
this.messageId = payload.optString("messageId", null); this.messageId = payload.path("messageId").asText(null);
emojiMapping.clear(); emojiMapping.clear();
JSONObject mapping = payload.optJSONObject("emojiMapping"); JsonNode mapping = payload.path("emojiMapping");
if (mapping != null) { if (mapping.isObject()) {
for (String emoji : mapping.keySet()) { mapping.properties().forEach(entry -> emojiMapping.put(entry.getKey(), entry.getValue().asText()));
emojiMapping.put(emoji, mapping.getString(emoji));
} }
} }
@Override
protected void writePayload(ObjectNode payload) {
putIfPresent(payload, "guildId", guildId);
putIfPresent(payload, "channelId", channelId);
putIfPresent(payload, "messageId", messageId);
ObjectNode mapping = payload.putObject("emojiMapping");
emojiMapping.forEach(mapping::put);
}
private static void putIfPresent(ObjectNode payload, String key, String value) {
if (value != null) {
payload.put(key, value);
}
} }
public String getGuildId() { public String getGuildId() {
@@ -5,12 +5,12 @@ package me.bobert.netneutrality.core.tasks.persistence;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.ReflectionHelper; import me.bobert.netneutrality.bootstrap.ReflectionHelper;
import me.bobert.netneutrality.core.tasks.PersistableTask; import me.bobert.netneutrality.core.tasks.PersistableTask;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import java.util.Collections; import java.util.Collections;
import java.util.Map; import java.util.Map;
@@ -88,7 +88,7 @@ public final class PersistableTaskRegistry {
* @throws IllegalArgumentException if no task type is registered under {@code typeId} * @throws IllegalArgumentException if no task type is registered under {@code typeId}
* @throws ReflectiveOperationException if the task type cannot be instantiated * @throws ReflectiveOperationException if the task type cannot be instantiated
*/ */
public PersistableTask create(String typeId, JSONObject payload) throws ReflectiveOperationException { public PersistableTask create(String typeId, ObjectNode payload) throws ReflectiveOperationException {
Class<? extends PersistableTask> type = typesById.get(typeId); Class<? extends PersistableTask> type = typesById.get(typeId);
if (type == null) { if (type == null) {
throw new IllegalArgumentException("No persistable task type registered for id: " + typeId); throw new IllegalArgumentException("No persistable task type registered for id: " + typeId);
@@ -5,14 +5,15 @@ package me.bobert.netneutrality.db.service;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.core.tasks.PersistableTask; import me.bobert.netneutrality.core.tasks.PersistableTask;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry; import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.entities.OTask; import me.bobert.netneutrality.db.entities.OTask;
import me.bobert.netneutrality.db.repository.TaskRepository; import me.bobert.netneutrality.db.repository.TaskRepository;
import me.bobert.netneutrality.utils.Json;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.json.JSONObject;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@@ -42,7 +43,7 @@ public class TaskService {
String payload = oTask.getPayload(); String payload = oTask.getPayload();
try { try {
JSONObject json = new JSONObject(payload); ObjectNode json = Json.readObject(payload);
PersistableTask task = registry.create(typeId, json); PersistableTask task = registry.create(typeId, json);
validateTask(task, oTask); validateTask(task, oTask);
@@ -61,13 +62,13 @@ public class TaskService {
throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId()); throw new IllegalArgumentException("Task ID mismatch, expected: " + task.getId());
} }
JSONObject json = new JSONObject(payload); ObjectNode json = Json.readObject(payload);
String payloadId = json.getString("id"); String payloadId = json.path("id").asText(null);
if (!Objects.equals(payloadId, task.getId())) { if (!Objects.equals(payloadId, task.getId())) {
throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId()); throw new IllegalArgumentException("Task payload ID mismatch, expected: " + task.getId());
} }
String payloadLockId = json.getString("lockId"); String payloadLockId = json.path("lockId").asText(null);
if (!Objects.equals(payloadLockId, task.getLockId())) { if (!Objects.equals(payloadLockId, task.getLockId())) {
throw new IllegalArgumentException("Task lock ID mismatch, expected: " + task.getLockId()); throw new IllegalArgumentException("Task lock ID mismatch, expected: " + task.getLockId());
} }
@@ -6,17 +6,17 @@ package me.bobert.netneutrality.http;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.google.gson.Gson; import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.sentry.Breadcrumb; import io.sentry.Breadcrumb;
import io.sentry.Sentry; import io.sentry.Sentry;
import me.bobert.netneutrality.http.ResponseCache.CachedResponse; import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
import me.bobert.netneutrality.utils.Json;
import okhttp3.MediaType; import okhttp3.MediaType;
import okhttp3.OkHttpClient; import okhttp3.OkHttpClient;
import okhttp3.Request; import okhttp3.Request;
import okhttp3.RequestBody; import okhttp3.RequestBody;
import okhttp3.Response; import okhttp3.Response;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -130,15 +130,15 @@ public class WebModule {
return keyBuilder.toString(); return keyBuilder.toString();
} }
public JSONObject getAsObject() { public ObjectNode getAsObject() {
return cachedResponse.getBody() return cachedResponse.getBody()
.map(JSONObject::new) .map(Json::readObject)
.orElseThrow(() -> new IllegalStateException("No body available for this response")); .orElseThrow(() -> new IllegalStateException("No body available for this response"));
} }
public JSONArray getAsArray() { public ArrayNode getAsArray() {
return cachedResponse.getBody() return cachedResponse.getBody()
.map(JSONArray::new) .map(Json::readArray)
.orElseThrow(() -> new IllegalStateException("No body available for this response")); .orElseThrow(() -> new IllegalStateException("No body available for this response"));
} }
@@ -253,7 +253,7 @@ public class WebModule {
} }
if (method == Method.POST && !params.isEmpty()) { if (method == Method.POST && !params.isEmpty()) {
RequestBody body = RequestBody.create(new Gson().toJson(params), MediaType.parse("application/json; charset=utf-8")); RequestBody body = RequestBody.create(Json.write(params), MediaType.parse("application/json; charset=utf-8"));
builder.post(body); builder.post(body);
} }
@@ -1,33 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import io.javalin.json.JsonMapper;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Type;
/**
* Javalin {@link JsonMapper} backed by a shared {@link Gson} instance.
*/
public class GsonJsonMapper implements JsonMapper {
private static final Gson GSON = new Gson();
@NotNull
@Override
public String toJsonString(@NotNull Object obj, @NotNull Type type) {
return GSON.toJson(obj, type);
}
@NotNull
@Override
public <T> T fromJsonString(@NotNull String json, @NotNull Type targetType) {
return GSON.fromJson(json, targetType);
}
}
@@ -57,7 +57,6 @@ public class WebApi implements Lifecycle {
// Javalin 7 requires routes, exception and error handlers to be registered upfront. // Javalin 7 requires routes, exception and error handlers to be registered upfront.
app = Javalin.create(config -> { app = Javalin.create(config -> {
config.jsonMapper(new GsonJsonMapper());
config.startup.showJavalinBanner = false; config.startup.showJavalinBanner = false;
config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML)); config.routes.get("/", ctx -> ctx.html(LIVENESS_HTML));
@@ -0,0 +1,99 @@
package me.bobert.netneutrality.utils;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* Process-wide JSON entry point backed by a single shared {@link ObjectMapper}.
*/
public final class Json {
private static final ObjectMapper MAPPER = new ObjectMapper();
private Json() {
}
public static ObjectMapper mapper() {
return MAPPER;
}
public static ObjectNode object() {
return MAPPER.createObjectNode();
}
public static ArrayNode array() {
return MAPPER.createArrayNode();
}
/**
* Parses a JSON document expected to be an object.
*
* @param json the raw JSON document
* @return the parsed object node
* @throws JsonException if the document is malformed or is not an object
*/
public static ObjectNode readObject(String json) {
JsonNode node = read(json);
if (!(node instanceof ObjectNode objectNode)) {
throw new JsonException("Expected a JSON object but got: " + node.getNodeType());
}
return objectNode;
}
/**
* Parses a JSON document expected to be an array.
*
* @param json the raw JSON document
* @return the parsed array node
* @throws JsonException if the document is malformed or is not an array
*/
public static ArrayNode readArray(String json) {
JsonNode node = read(json);
if (!(node instanceof ArrayNode arrayNode)) {
throw new JsonException("Expected a JSON array but got: " + node.getNodeType());
}
return arrayNode;
}
public static JsonNode read(String json) {
try {
return MAPPER.readTree(json);
} catch (JsonProcessingException e) {
throw new JsonException("Cannot parse JSON document", e);
}
}
public static String write(Object value) {
try {
return MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
throw new JsonException("Cannot serialize value to JSON", e);
}
}
/**
* Unchecked wrapper around Jackson's checked processing exceptions.
*/
public static class JsonException extends RuntimeException {
public JsonException(String message) {
super(message);
}
public JsonException(String message, Throwable cause) {
super(message, cause);
}
}
}
@@ -6,9 +6,9 @@ package me.bobert.netneutrality.utils;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ArrayNode;
import me.bobert.netneutrality.http.WebModule; import me.bobert.netneutrality.http.WebModule;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.json.JSONArray;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
@@ -101,11 +101,11 @@ public class Settings implements SettingsProvider {
String gitlabProjectId = "22"; String gitlabProjectId = "22";
String latestReleaseUrl = String.format("%s/api/v4/projects/%s/releases/", gitlabHost, gitlabProjectId); String latestReleaseUrl = String.format("%s/api/v4/projects/%s/releases/", gitlabHost, gitlabProjectId);
JSONArray json = new WebModule.Builder(latestReleaseUrl).cache(1, TimeUnit.DAYS).build().request().getAsArray(); ArrayNode json = new WebModule.Builder(latestReleaseUrl).cache(1, TimeUnit.DAYS).build().request().getAsArray();
if (json.isNull(0)) { if (json.isEmpty() || !json.get(0).hasNonNull("tag_name")) {
return "0.0.0"; return "0.0.0";
} }
return json.getJSONObject(0).getString("tag_name"); return json.get(0).get("tag_name").asText();
} }
} }
@@ -214,7 +214,7 @@ class TaskManagerImplTest {
} }
@Override @Override
public void restore(org.json.JSONObject payload) { public void restore(com.fasterxml.jackson.databind.node.ObjectNode payload) {
} }
@Override @Override
@@ -12,7 +12,7 @@ import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.impl.AbstractPersistableTask; import me.bobert.netneutrality.core.tasks.impl.AbstractPersistableTask;
import me.bobert.netneutrality.core.tasks.impl.EmojiTask; import me.bobert.netneutrality.core.tasks.impl.EmojiTask;
import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask; import me.bobert.netneutrality.tasks.reaction.EmojiReactionRoleTask;
import org.json.JSONObject; import me.bobert.netneutrality.utils.Json;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -49,7 +49,7 @@ class PersistableTaskRegistryTest {
registry.scan(); registry.scan();
for (String typeId : registry.getTypeIds()) { for (String typeId : registry.getTypeIds()) {
Class<?> type = registry.create(typeId, new JSONObject()).getClass(); Class<?> type = registry.create(typeId, Json.object()).getClass();
assertFalse(Modifier.isAbstract(type.getModifiers()), type.getName() + " must not be abstract"); assertFalse(Modifier.isAbstract(type.getModifiers()), type.getName() + " must not be abstract");
assertNotEquals(AbstractPersistableTask.class, type); assertNotEquals(AbstractPersistableTask.class, type);
@@ -61,7 +61,7 @@ class PersistableTaskRegistryTest {
void createInstantiatesRegisteredTypeThroughDi() throws Exception { void createInstantiatesRegisteredTypeThroughDi() throws Exception {
registry.scan(); registry.scan();
PersistableTask task = registry.create("emoji-reaction-role", new JSONObject()); PersistableTask task = registry.create("emoji-reaction-role", Json.object());
assertInstanceOf(EmojiReactionRoleTask.class, task); assertInstanceOf(EmojiReactionRoleTask.class, task);
} }
@@ -70,7 +70,7 @@ class PersistableTaskRegistryTest {
void createRejectsUnknownTypeId() { void createRejectsUnknownTypeId() {
registry.scan(); registry.scan();
assertThrows(IllegalArgumentException.class, () -> registry.create("does-not-exist", new JSONObject())); assertThrows(IllegalArgumentException.class, () -> registry.create("does-not-exist", Json.object()));
} }
@Test @Test
@@ -45,9 +45,9 @@ class WebModuleTest {
String url = url("cache-hit"); String url = url("cache-hit");
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request() assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits")); .getAsObject().get("hits").asInt());
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request() assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits")); .getAsObject().get("hits").asInt());
assertEquals(1, hits.get()); assertEquals(1, hits.get());
} }
@@ -5,12 +5,13 @@ package me.bobert.netneutrality.tasks.reaction;
* https://discord.gg/qUa6zNFZsd * https://discord.gg/qUa6zNFZsd
*/ */
import com.fasterxml.jackson.databind.node.ObjectNode;
import me.bobert.netneutrality.bootstrap.ApplicationContext; import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.core.tasks.TaskManager; import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.core.tasks.impl.EmojiTask; import me.bobert.netneutrality.core.tasks.impl.EmojiTask;
import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry; import me.bobert.netneutrality.core.tasks.persistence.PersistableTaskRegistry;
import me.bobert.netneutrality.db.service.TaskService; import me.bobert.netneutrality.db.service.TaskService;
import org.json.JSONObject; import me.bobert.netneutrality.utils.Json;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -45,7 +46,7 @@ class EmojiReactionRoleTaskPersistenceTest {
Map<String, String> mapping = Map.of("\uD83D\uDD25", "role-1", "\u2705", "role-2"); Map<String, String> mapping = Map.of("\uD83D\uDD25", "role-1", "\u2705", "role-2");
EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1", mapping); EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1", mapping);
JSONObject payload = new JSONObject(original.toPayload()); ObjectNode payload = Json.readObject(original.toPayload());
EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload)); EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload));
assertEquals(original.getId(), restored.getId()); assertEquals(original.getId(), restored.getId());
@@ -68,17 +69,17 @@ class EmojiReactionRoleTaskPersistenceTest {
void payloadAlwaysCarriesIdAndLockId() { void payloadAlwaysCarriesIdAndLockId() {
EmojiReactionRoleTask task = new EmojiReactionRoleTask(taskManager, "g", "c", "m"); EmojiReactionRoleTask task = new EmojiReactionRoleTask(taskManager, "g", "c", "m");
JSONObject payload = new JSONObject(task.toPayload()); ObjectNode payload = Json.readObject(task.toPayload());
assertEquals(task.getId(), payload.getString("id")); assertEquals(task.getId(), payload.get("id").asText());
assertEquals(task.getLockId(), payload.getString("lockId")); assertEquals(task.getLockId(), payload.get("lockId").asText());
} }
@Test @Test
void missingEmojiMappingRestoresToEmptyMap() throws Exception { void missingEmojiMappingRestoresToEmptyMap() throws Exception {
EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1"); EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1");
JSONObject payload = new JSONObject(original.toPayload()); ObjectNode payload = Json.readObject(original.toPayload());
payload.remove("emojiMapping"); payload.remove("emojiMapping");
EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload)); EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload));
@@ -90,7 +91,7 @@ class EmojiReactionRoleTaskPersistenceTest {
void restoredTaskHasInjectedTaskManager() throws Exception { void restoredTaskHasInjectedTaskManager() throws Exception {
EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1"); EmojiReactionRoleTask original = new EmojiReactionRoleTask(taskManager, "guild-1", "channel-1", "message-1");
JSONObject payload = new JSONObject(original.toPayload()); ObjectNode payload = Json.readObject(original.toPayload());
EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload)); EmojiTask restored = assertInstanceOf(EmojiTask.class, registry.create(original.typeId(), payload));
restored.addEmoji("\uD83D\uDD25", "role-1"); restored.addEmoji("\uD83D\uDD25", "role-1");
@@ -98,4 +99,19 @@ class EmojiReactionRoleTaskPersistenceTest {
assertEquals("role-1", restored.getEmojiMapping().get("\uD83D\uDD25")); assertEquals("role-1", restored.getEmojiMapping().get("\uD83D\uDD25"));
verify(taskService).persistTask(restored); verify(taskService).persistTask(restored);
} }
@Test
void legacyPayloadStillRestores() throws Exception {
String legacy = "{\"id\":\"emoji-reaction-role-guild-1-message-1\",\"lockId\":\"lock-1\",\"name\":\"emoji-reaction-role\","
+ "\"guildId\":\"guild-1\",\"channelId\":\"channel-1\",\"messageId\":\"message-1\","
+ "\"emojiMapping\":{\"\uD83D\uDD25\":\"role-1\"}}";
EmojiTask restored = assertInstanceOf(EmojiTask.class,
registry.create("emoji-reaction-role", Json.readObject(legacy)));
assertEquals("guild-1", restored.getGuildId());
assertEquals("channel-1", restored.getChannelId());
assertEquals("message-1", restored.getMessageId());
assertEquals(Map.of("\uD83D\uDD25", "role-1"), restored.getEmojiMapping());
}
} }