Files
netneutrality/.junie/plans/consolidate-json-on-jackson.md
T
anthorob 05b4a2ab85 Refactor persistable-task system with self-registering tasks
Replaced metadata-driven reflection with self-describing `PersistableTask` types and a new `PersistableTaskRegistry`. Removed `persistable-tasks.json` and `TaskRegistry`, migrating to type-safe DI instantiation and explicit `restore()` methods. Fixed task restoration issues, ensuring `EmojiReactionRoleTask` and its `emojiMapping` now persist and restore correctly. Introduced `typeId()` for stable task identification, updated payload serialization for symmetry, and applied database migration to map FQCNs to logical IDs. Added unit tests for robust type discovery, payload symmetry, and registry behavior, ensuring no breaking changes.
2026-09-25 07:35:45 -04:00

16 KiB

sessionId
sessionId
session-260925-072402-zipx

Requirements

Overview & Goals

The project currently depends on two JSON libraries declared in pom.xml:

  • org.json:json:20250517 (line 220) — used widely
  • com.google.code.gson:gson:2.14.0 (line 225) — used in exactly two places

Goal: standardize on one JSON library across the whole codebase and remove the others from pom.xml.

Recommendation: Jackson (com.fasterxml.jackson). See the Library Comparison tab for the rationale — the decisive point is that Jackson is already on the compile classpath (2.21.2, transitively via JDA 6.4.1 and Javalin 7.2.3), so consolidating on it removes both declared dependencies instead of one.

Scope

In scope

  • Add an explicit, version-managed jackson-databind dependency.
  • Migrate all org.json usages (JSONObject / JSONArray) to Jackson JsonNode / ObjectNode / ArrayNode.
  • Migrate the two Gson usages to Jackson.
  • Remove org.json:json and com.google.code.gson:gson from pom.xml.
  • Update the two tests that build JSONObject payloads.

Out of scope

  • Changing the persisted JSON format stored in the database or state.json — the on-the-wire/on-disk shape must stay byte-compatible in meaning so existing persisted tasks keep loading.
  • Refactoring unrelated logic in the touched files (e.g. the dead loop in BotState.saveGuildsState).
  • Replacing JDA's or Sentry's internal JSON handling.

Functional Requirements

  • The bot compiles and all existing tests pass with only one JSON library declared.
  • Tasks persisted before the change still restore correctly (same field names: id, lockId, guildId, channelId, messageId, emojiMapping).
  • The Javalin HTTP server keeps serializing/deserializing request and response bodies identically.
  • Settings.getGitVersion() and CryptoPriceCommand keep reading the same fields from the same remote APIs.

Non-Functional Requirements

  • No new transitive dependency conflicts in the shaded jar.
  • Jackson version stays aligned with the one JDA brings in, to avoid split-version issues.

Library Comparison

The three candidates

org.json Gson Jackson
Already on classpath? declared declared yes, transitively (JDA + Javalin)
Deps removable if chosen 1 (gson) 1 (org.json) 2 (both)
Javalin 7 integration none (manual) custom GsonJsonMapper needed built-in JavalinJackson
Tree/DOM API (needed by this codebase) JSONObject weak (JsonObject, clunky) ObjectNode / JsonNode
POJO binding none good excellent
Current usage count ~9 files 2 call sites 0

Why Jackson wins here

  1. It is already a compile dependency. Verified via mvn dependency:tree:

    • net.dv8tion:JDA:6.4.1 → jackson-core:2.21.2, jackson-databind:2.21.2
    • io.javalin:javalin:7.2.3 → jersey-media-json-jackson → jackson modules

    So Jackson ships in the jar today whether we use it or not. Choosing org.json or Gson means keeping a redundant second parser in the artifact forever.

  2. Javalin has first-class Jackson support. GsonJsonMapper.java exists purely to bridge Gson into Javalin; with Jackson that whole class is deleted and Javalin's default mapper takes over.

  3. Jackson covers both usage styles present in the codebase: the tree-style manipulation org.json is used for (ObjectNode/ArrayNode) and the POJO serialization Gson is used for (writeValueAsString).

Why not the other two

  • Gson: the smaller migration on paper is actually the bigger one — all ~9 org.json files would move to Gson's awkward tree API, and Jackson would still be shipped unused.
  • org.json: only 2 call sites to change (trivial), but it is the weakest library of the three (no POJO binding, new JSONObject(bean) reflection is fragile), it has no Javalin integration, and Jackson still ships unused. Choose this only if minimal diff outweighs everything else.

Cost

Jackson is the largest diff (~11 files) but the only option that actually reduces what is bundled.

Technical Design

Current Implementation

Gson (2 sites — trivial):

  • http/server/GsonJsonMapper.java — implements Javalin's JsonMapper over a static Gson.
  • http/WebModule.java:256 — RequestBody.create(new Gson().toJson(params), ...) for POST bodies.

org.json (9 files):

  • core/tasks/PersistableTask.java — interface declares void restore(JSONObject payload).
  • core/tasks/impl/AbstractPersistableTask.java:69-80 — restore(JSONObject) no-op and toPayload() using new JSONObject(this) bean introspection plus id / lockId.
  • core/tasks/impl/EmojiTask.java:39-51 — restore reads guildId, channelId, messageId, and the emojiMapping object.
  • core/tasks/persistence/PersistableTaskRegistry.java, db/service/TaskService.java — parse/handle payload JSON.
  • http/WebModule.java:133-143 — getAsObject() / getAsArray() returning JSONObject / JSONArray.
  • utils/Settings.java:104-109 — getAsArray(), isNull(0), getJSONObject(0).getString("tag_name").
  • command/crypto/CryptoPriceCommand.java — reads the crypto API response.
  • bootstrap/BotState.java:46-81 — builds a state.json dump.
  • Tests: PersistableTaskRegistryTest, EmojiReactionRoleTaskPersistenceTest.

tasks/reaction/EmojiReactionRoleTask.java itself has no JSON imports — it is affected only indirectly through EmojiTask.

Key Decisions

  1. Jackson as the single library, pinned to the version JDA supplies (2.21.2) via an explicit <dependency> on jackson-databind so the build does not silently depend on a transitive.
  2. One shared ObjectMapper in a small utils/Json.java holder — ObjectMapper is thread-safe and expensive to create; today the code does new Gson() per request.
  3. Replace new JSONObject(this) bean introspection with explicit payload building. Jackson's valueToTree(this) would walk every getter on the task hierarchy (including ones returning JDA objects) and can blow up. Instead toPayload() builds an ObjectNode explicitly and tasks with extra state override a writePayload(ObjectNode) hook — mirroring the existing restore hook. This keeps the emitted field set identical to today's (id, lockId, guildId, channelId, messageId, emojiMapping, name) but makes it deterministic.
  4. Delete GsonJsonMapper and rely on Javalin's built-in Jackson mapper rather than porting the class to Jackson.
  5. Keep tree-style code tree-style. No POJO/DTO redesign in this task — JSONObject → ObjectNode, JSONArray → ArrayNode, optString(k, null) → node.path(k).asText(null).

Data Models / Contracts

// utils/Json.java (new)
public final class Json {
    private static final ObjectMapper MAPPER = new ObjectMapper();
    public static ObjectMapper mapper();
    public static ObjectNode object();          // MAPPER.createObjectNode()
    public static ArrayNode array();
    public static ObjectNode readObject(String json);   // wraps JsonProcessingException
    public static ArrayNode  readArray(String json);
    public static String write(Object value);
}

Interface change in PersistableTask:

- void restore(JSONObject payload);
+ void restore(ObjectNode payload);

New hook in AbstractPersistableTask:

protected void writePayload(ObjectNode payload) { /* no-op */ }

@Override
public String toPayload() {
    ObjectNode payload = Json.object();
    payload.put("id", getId());
    payload.put("lockId", getLockId());
    payload.put("name", getName());
    writePayload(payload);
    return payload.toString();
}

EmojiTask gains the symmetric writer:

@Override protected void writePayload(ObjectNode payload) {
    payload.put("guildId", guildId);
    payload.put("channelId", channelId);
    payload.put("messageId", messageId);
    ObjectNode mapping = payload.putObject("emojiMapping");
    emojiMapping.forEach(mapping::put);
}

WebModule response accessors:

public ObjectNode getAsObject();   // Json.readObject(body)
public ArrayNode  getAsArray();    // Json.readArray(body)

Both wrap Jackson's checked JsonProcessingException into an unchecked exception so the existing call sites' signatures are unchanged.

File Structure

Added

  • src/main/java/me/bobert/netneutrality/utils/Json.java

Removed

  • src/main/java/me/bobert/netneutrality/http/server/GsonJsonMapper.java

Modified

  • pom.xml — drop org.json:json + com.google.code.gson:gson, add jackson-databind
  • core/tasks/PersistableTask.java
  • core/tasks/impl/AbstractPersistableTask.java
  • core/tasks/impl/EmojiTask.java
  • core/tasks/persistence/PersistableTaskRegistry.java
  • db/service/TaskService.java
  • http/WebModule.java
  • wherever GsonJsonMapper was registered on the Javalin config (http/server/*)
  • utils/Settings.java
  • command/crypto/CryptoPriceCommand.java
  • bootstrap/BotState.java
  • src/test/java/.../PersistableTaskRegistryTest.java
  • src/test/java/.../EmojiReactionRoleTaskPersistenceTest.java

Architecture Diagram

graph LR
  A[Task persistence] --> J[Json utils - ObjectMapper]
  B[WebModule HTTP client] --> J
  C[Settings / CryptoPriceCommand] --> B
  D[BotState dump] --> J
  E[Javalin server] --> F[JavalinJackson built-in]
  J --> G[jackson-databind]
  F --> G

Risks

  • Behavioural drift in toPayload(): new JSONObject(this) reflects over all getters. If any persisted consumer relies on an incidental field, the explicit writer could drop it. Mitigation: inspect the DB payload column / existing tests and keep the field set identical.
  • Null handling differs: JSONObject.put(k, null) removes the key; ObjectNode.put(k, (String) null) writes a JSON null. EmojiTask.restore uses optString(k, null), so writers must skip nulls to stay equivalent.
  • JSONArray.isNull(0) has no direct Jackson twin — Settings.getGitVersion() must instead check array.isEmpty() || !array.get(0).hasNonNull("tag_name").
  • Checked exceptions: Jackson parsing throws JsonProcessingException; must be wrapped to avoid rippling throws through call sites.
  • Shade plugin: confirm no relocation/filter rules in pom.xml reference org.json or gson.

Testing

Validation Approach

  • ./mvnw -o clean verify must pass with the two dependencies removed — a leftover org.json/gson import then becomes a hard compile error, which is the primary completeness check.
  • Grep the whole src tree for org.json, com.google.gson, JSONObject, JSONArray, Gson and confirm zero hits.
  • ./mvnw -o dependency:tree to confirm org.json:json and com.google.code.gson:gson are fully gone (not even transitive) and only one Jackson version is resolved.

Key Scenarios

  • Task round-trip: EmojiReactionRoleTaskPersistenceTest — a task is persisted via toPayload() and restored via restore(...) with guildId / channelId / messageId / emojiMapping intact.
  • Registry restore: PersistableTaskRegistryTest — a payload built in the test is resolved to the right task type and hydrated.
  • Backward compatibility: add an assertion that a payload string in the old org.json format still restores correctly, proving pre-existing DB rows keep working.
  • HTTP client: WebModule.getAsObject() / getAsArray() parse a sample body; the POST path produces the same JSON body as Gson did for a Map<String,String>.

Edge Cases

  • Missing / null guildId in a payload → restore yields null, not the string "null".
  • Empty array from the GitLab releases endpoint → Settings.getGitVersion() returns "0.0.0" instead of throwing.
  • Malformed response body → the wrapped unchecked exception surfaces with a useful message rather than an NPE.

Test Changes

  • Update the two existing test files to build payloads with ObjectNode.
  • Add a legacy-payload restore test in EmojiReactionRoleTaskPersistenceTest.
  • Optionally add a small JsonTest covering Json.readObject / readArray error wrapping.

Delivery Steps

* Step 1: Introduce Jackson and the shared Json utility

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).
  • Create src/main/java/me/bobert/netneutrality/utils/Json.java holding a static thread-safe ObjectMapper plus helpers: mapper(), object(), array(), readObject(String), readArray(String), write(Object).
  • 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.

Step 2: Migrate the Gson call sites to Jackson

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.
  • 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.

Step 3: Migrate the HTTP client and its consumers

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.
  • Update utils/Settings.java:104-109: replace JSONArray/isNull(0)/getJSONObject(0).getString("tag_name") with an ArrayNode emptiness check and get(0).path("tag_name").asText(), preserving the "0.0.0" fallback.
  • 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.

Step 4: Migrate the task persistence layer

Tasks serialize and restore through Jackson while keeping the existing on-disk payload shape.

  • Change PersistableTask.restore(JSONObject) to restore(ObjectNode).
  • In AbstractPersistableTask, replace the new JSONObject(this) bean-introspection toPayload() with an explicit ObjectNode builder writing id, lockId and name, plus a new protected void writePayload(ObjectNode) hook; keep restore a no-op default.
  • In EmojiTask, rewrite restore (lines 39-51) using payload.path(...).asText(null) and iteration over the emojiMapping node, and implement writePayload to emit guildId, channelId, messageId and emojiMapping, skipping nulls to match optString(k, null) semantics.
  • 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.

Step 5: Update tests and drop the old dependencies

org.json and Gson are removed from pom.xml and the full build passes.

  • Rewrite payload construction in PersistableTaskRegistryTest and EmojiReactionRoleTaskPersistenceTest using ObjectNode.
  • Add a regression test restoring a payload written in the legacy org.json format to prove pre-existing database rows still load.
  • Migrate the state.json dump in bootstrap/BotState.java:46-81 to ObjectNode/ArrayNode.
  • Remove the org.json:json and com.google.code.gson:gson dependencies from pom.xml.
  • Run ./mvnw -o clean verify, then dependency:tree to confirm both libraries are fully absent and a single Jackson version is resolved.