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.
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 widelycom.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-databinddependency. - Migrate all
org.jsonusages (JSONObject/JSONArray) to JacksonJsonNode/ObjectNode/ArrayNode. - Migrate the two Gson usages to Jackson.
- Remove
org.json:jsonandcom.google.code.gson:gsonfrompom.xml. - Update the two tests that build
JSONObjectpayloads.
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()andCryptoPriceCommandkeep 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
-
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.2io.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.jsonor Gson means keeping a redundant second parser in the artifact forever. -
Javalin has first-class Jackson support.
GsonJsonMapper.javaexists purely to bridge Gson into Javalin; with Jackson that whole class is deleted and Javalin's default mapper takes over. -
Jackson covers both usage styles present in the codebase: the tree-style manipulation
org.jsonis 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.jsonfiles 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'sJsonMapperover a staticGson.http/WebModule.java:256—RequestBody.create(new Gson().toJson(params), ...)for POST bodies.
org.json (9 files):
core/tasks/PersistableTask.java— interface declaresvoid restore(JSONObject payload).core/tasks/impl/AbstractPersistableTask.java:69-80—restore(JSONObject)no-op andtoPayload()usingnew JSONObject(this)bean introspection plusid/lockId.core/tasks/impl/EmojiTask.java:39-51—restorereadsguildId,channelId,messageId, and theemojiMappingobject.core/tasks/persistence/PersistableTaskRegistry.java,db/service/TaskService.java— parse/handle payload JSON.http/WebModule.java:133-143—getAsObject()/getAsArray()returningJSONObject/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 astate.jsondump.- Tests:
PersistableTaskRegistryTest,EmojiReactionRoleTaskPersistenceTest.
tasks/reaction/EmojiReactionRoleTask.java itself has no JSON imports — it is affected only indirectly through EmojiTask.
Key Decisions
- Jackson as the single library, pinned to the version JDA supplies (2.21.2) via an explicit
<dependency>onjackson-databindso the build does not silently depend on a transitive. - One shared
ObjectMapperin a smallutils/Json.javaholder —ObjectMapperis thread-safe and expensive to create; today the code doesnew Gson()per request. - Replace
new JSONObject(this)bean introspection with explicit payload building. Jackson'svalueToTree(this)would walk every getter on the task hierarchy (including ones returning JDA objects) and can blow up. InsteadtoPayload()builds anObjectNodeexplicitly and tasks with extra state override awritePayload(ObjectNode)hook — mirroring the existingrestorehook. This keeps the emitted field set identical to today's (id,lockId,guildId,channelId,messageId,emojiMapping,name) but makes it deterministic. - Delete
GsonJsonMapperand rely on Javalin's built-in Jackson mapper rather than porting the class to Jackson. - 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— droporg.json:json+com.google.code.gson:gson, addjackson-databindcore/tasks/PersistableTask.javacore/tasks/impl/AbstractPersistableTask.javacore/tasks/impl/EmojiTask.javacore/tasks/persistence/PersistableTaskRegistry.javadb/service/TaskService.javahttp/WebModule.java- wherever
GsonJsonMapperwas registered on the Javalin config (http/server/*) utils/Settings.javacommand/crypto/CryptoPriceCommand.javabootstrap/BotState.javasrc/test/java/.../PersistableTaskRegistryTest.javasrc/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 JSONnull.EmojiTask.restoreusesoptString(k, null), so writers must skip nulls to stay equivalent. JSONArray.isNull(0)has no direct Jackson twin —Settings.getGitVersion()must instead checkarray.isEmpty() || !array.get(0).hasNonNull("tag_name").- Checked exceptions: Jackson parsing throws
JsonProcessingException; must be wrapped to avoid ripplingthrowsthrough call sites. - Shade plugin: confirm no relocation/filter rules in
pom.xmlreferenceorg.jsonorgson.
Testing
Validation Approach
./mvnw -o clean verifymust pass with the two dependencies removed — a leftoverorg.json/gsonimport then becomes a hard compile error, which is the primary completeness check.- Grep the whole
srctree fororg.json,com.google.gson,JSONObject,JSONArray,Gsonand confirm zero hits. ./mvnw -o dependency:treeto confirmorg.json:jsonandcom.google.code.gson:gsonare fully gone (not even transitive) and only one Jackson version is resolved.
Key Scenarios
- Task round-trip:
EmojiReactionRoleTaskPersistenceTest— a task is persisted viatoPayload()and restored viarestore(...)withguildId/channelId/messageId/emojiMappingintact. - 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.jsonformat 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 aMap<String,String>.
Edge Cases
- Missing / null
guildIdin a payload →restoreyieldsnull, 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
JsonTestcoveringJson.readObject/readArrayerror 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-databindtopom.xml, pinned to the version already resolved via JDA (2.21.2), next to the existingorg.json/gsonentries (which stay for now so the build keeps compiling). - Create
src/main/java/me/bobert/netneutrality/utils/Json.javaholding a static thread-safeObjectMapperplus helpers:mapper(),object(),array(),readObject(String),readArray(String),write(Object). - Have
readObject/readArraywrap Jackson's checkedJsonProcessingExceptionin an unchecked exception so existing call-site signatures stay unchanged. - Verify no shade-plugin relocation or filter rules in
pom.xmlreferenceorg.jsonorgson.
✓ Step 2: Migrate the Gson call sites to Jackson
Gson is no longer referenced anywhere in the source tree.
- Delete
http/server/GsonJsonMapper.javaand remove its registration from the Javalin configuration, relying on Javalin 7's built-in JacksonJsonMapperinstead. - Replace
new Gson().toJson(params)athttp/WebModule.java:256withJson.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 returnObjectNodeandgetAsArray()to returnArrayNode(lines 133-143), parsing throughJson. - Update
utils/Settings.java:104-109: replaceJSONArray/isNull(0)/getJSONObject(0).getString("tag_name")with anArrayNodeemptiness check andget(0).path("tag_name").asText(), preserving the"0.0.0"fallback. - Update
command/crypto/CryptoPriceCommand.javato read the crypto API response viaJsonNodeaccessors. - Remove the now-unused
org.jsonimports 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)torestore(ObjectNode). - In
AbstractPersistableTask, replace thenew JSONObject(this)bean-introspectiontoPayload()with an explicitObjectNodebuilder writingid,lockIdandname, plus a newprotected void writePayload(ObjectNode)hook; keeprestorea no-op default. - In
EmojiTask, rewriterestore(lines 39-51) usingpayload.path(...).asText(null)and iteration over theemojiMappingnode, and implementwritePayloadto emitguildId,channelId,messageIdandemojiMapping, skipping nulls to matchoptString(k, null)semantics. - Update
PersistableTaskRegistryanddb/service/TaskServiceto parse stored payload strings viaJson.readObject. - Confirm
tasks/reaction/EmojiReactionRoleTaskstill compiles unchanged against the newEmojiTaskcontract.
✓ 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
PersistableTaskRegistryTestandEmojiReactionRoleTaskPersistenceTestusingObjectNode. - Add a regression test restoring a payload written in the legacy
org.jsonformat to prove pre-existing database rows still load. - Migrate the
state.jsondump inbootstrap/BotState.java:46-81toObjectNode/ArrayNode. - Remove the
org.json:jsonandcom.google.code.gson:gsondependencies frompom.xml. - Run
./mvnw -o clean verify, thendependency:treeto confirm both libraries are fully absent and a single Jackson version is resolved.