Jackson 2 to 3 migration companion code
Three Maven modules - jackson2-before (2.22.1), jackson3-after (3.2.1) and a coexistence module with BOTH majors on one classpath - so every claim in the two migration guides is executed rather than asserted. Paired class names make the before/after outputs directly diffable via run-all.sh. Confirms the guides on wire-format equivalence (10-case suite, zero mismatches), classpath coexistence and the collapse of four artifacts into one. Corrects nine points, including that enableDefaultTyping() is still present in Jackson 2.22.1 rather than removed in 2.16, and that the published "after" mapper snippet does not compile.
This commit is contained in:
45
jackson3-after/pom.xml
Normal file
45
jackson3-after/pom.xml
Normal file
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>jackson2-to-3-migration</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>jackson3-after</artifactId>
|
||||
<name>jackson3-after</name>
|
||||
<description>The "after" side: Jackson 3, one dependency, no compiler flags</description>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>tools.jackson</groupId>
|
||||
<artifactId>jackson-bom</artifactId>
|
||||
<version>${jackson3.version}</version>
|
||||
<type>pom</type>
|
||||
<scope>import</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<!-- One artifact replaces all four from jackson2-before. jsr310, jdk8 and
|
||||
parameter-names are folded into jackson-databind 3.x. -->
|
||||
<dependency>
|
||||
<groupId>tools.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.codehaus.mojo</groupId>
|
||||
<artifactId>exec-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.SerializationFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-migration-guide/ (Steps 2 and 3)
|
||||
*
|
||||
* Four corrections to the "after" snippet printed in the post:
|
||||
*
|
||||
* 1. .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) DOES NOT COMPILE.
|
||||
* That constant is not on SerializationFeature in Jackson 3 — it moved to
|
||||
* tools.jackson.databind.cfg.DateTimeFeature and defaults to OFF, so the line
|
||||
* is both invalid and unnecessary. Deleted here.
|
||||
* 2. .serializationInclusion(...) DOES NOT EXIST on JsonMapper.Builder. The real
|
||||
* API is changeDefaultPropertyInclusion(UnaryOperator).
|
||||
* 3. FAIL_ON_UNKNOWN_PROPERTIES already defaults to false; the configure() call is
|
||||
* kept only to show the equivalence, and could be deleted.
|
||||
* 4. The mapper cannot be mutated afterwards, which is the actual point.
|
||||
*/
|
||||
public class S01MapperConstruction {
|
||||
|
||||
public record Booking(Long id, LocalDate travelDate, Optional<String> seatPreference, String note) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
JsonMapper mapper = JsonMapper.builder()
|
||||
// JavaTimeModule and Jdk8Module registrations deleted — both are
|
||||
// built into jackson-databind 3.x and the classes no longer exist.
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||
.changeDefaultPropertyInclusion(
|
||||
incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
|
||||
.build();
|
||||
|
||||
Booking booking = new Booking(1L, LocalDate.of(2026, 9, 15), Optional.of("aisle"), null);
|
||||
System.out.println("output : " + mapper.writeValueAsString(booking));
|
||||
|
||||
// Varying configuration without touching the shared instance: fork it.
|
||||
JsonMapper indented = mapper.rebuild()
|
||||
.enable(SerializationFeature.INDENT_OUTPUT)
|
||||
.build();
|
||||
System.out.println("forked indented : "
|
||||
+ indented.writeValueAsString(booking).replace("\n", " ").replaceAll("\\s+", " "));
|
||||
System.out.println("original intact : "
|
||||
+ mapper.serializationConfig().isEnabled(SerializationFeature.INDENT_OUTPUT));
|
||||
|
||||
System.out.println("set*() mutators : " + java.util.Arrays.stream(
|
||||
tools.jackson.databind.ObjectMapper.class.getMethods())
|
||||
.filter(m -> m.getName().startsWith("set")).count()
|
||||
+ " <- mutation is impossible, not merely discouraged");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.StringReader;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "JacksonException Is Now Unchecked: A Silent Production Bug"
|
||||
*
|
||||
* The post is right that this is the dangerous one, but the failure mode is more
|
||||
* specific than "your catch blocks stop working". There are two cases:
|
||||
*
|
||||
* A. try block contains ONLY Jackson calls -> `catch (IOException)` is now a
|
||||
* COMPILE ERROR ("never thrown in body"). The compiler catches it for you.
|
||||
* B. try block also does real I/O -> IOException is still reachable, the catch
|
||||
* block compiles, and it silently stops covering the Jackson call.
|
||||
*
|
||||
* Case B is the one that reaches production, and it is what runs below.
|
||||
*/
|
||||
public class S02ExceptionHierarchy {
|
||||
|
||||
public record OrderDto(Long orderId, String customerName) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
System.out.println("JacksonException extends RuntimeException : "
|
||||
+ RuntimeException.class.isAssignableFrom(JacksonException.class));
|
||||
System.out.println("JacksonException extends IOException : "
|
||||
+ IOException.class.isAssignableFrom(JacksonException.class));
|
||||
|
||||
JsonMapper mapper = JsonMapper.builder().build();
|
||||
String malformed = "{\"orderId\": not-json}";
|
||||
|
||||
// CASE B — compiles, and misses the Jackson failure entirely.
|
||||
try {
|
||||
try (StringReader reader = new StringReader(malformed)) {
|
||||
reader.read(); // keeps IOException reachable
|
||||
mapper.readValue(malformed, OrderDto.class);
|
||||
System.out.println("unreachable");
|
||||
} catch (IOException e) {
|
||||
System.out.println("caught by catch (IOException) : unreachable");
|
||||
}
|
||||
} catch (JacksonException escaped) {
|
||||
System.out.println("ESCAPED catch (IOException) : " + escaped.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
// The fix: catch JacksonException explicitly, before IOException.
|
||||
try (StringReader reader = new StringReader(malformed)) {
|
||||
reader.read();
|
||||
mapper.readValue(malformed, OrderDto.class);
|
||||
} catch (JacksonException e) {
|
||||
System.out.println("caught by catch (Jackson...) : " + e.getClass().getSimpleName());
|
||||
} catch (IOException e) {
|
||||
System.out.println("I/O : " + e.getClass().getSimpleName());
|
||||
}
|
||||
|
||||
System.out.println("writeValueAsString declares : "
|
||||
+ java.util.Arrays.toString(declaredExceptions()) + " <- nothing checked");
|
||||
}
|
||||
|
||||
private static Class<?>[] declaredExceptions() {
|
||||
try {
|
||||
return ObjectMapper.class.getMethod("writeValueAsString", Object.class).getExceptionTypes();
|
||||
} catch (NoSuchMethodException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.core.JsonParser;
|
||||
import tools.jackson.databind.DeserializationContext;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.ValueDeserializer;
|
||||
import tools.jackson.databind.ValueSerializer;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
import tools.jackson.databind.module.SimpleModule;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "Custom Serializers and Deserializers: Class Renames"
|
||||
*
|
||||
* The same handlers with every rename applied. The `throws IOException` clauses are
|
||||
* gone, p.getCodec().readTree(p) is replaced by ctxt.readTree(p), and the module is
|
||||
* attached to the BUILDER because the mapper is immutable once built.
|
||||
*/
|
||||
public class S03CustomHandlers {
|
||||
|
||||
public record Money(BigDecimal amount, String currencyCode) { }
|
||||
|
||||
static class MoneySerializer extends ValueSerializer<Money> {
|
||||
@Override
|
||||
public void serialize(Money value, JsonGenerator gen, SerializationContext ctxt) {
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberProperty("amount", value.amount());
|
||||
gen.writeStringProperty("currency", value.currencyCode().toUpperCase());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
|
||||
static class MoneyDeserializer extends ValueDeserializer<Money> {
|
||||
@Override
|
||||
public Money deserialize(JsonParser p, DeserializationContext ctxt) {
|
||||
JsonNode node = ctxt.readTree(p);
|
||||
return new Money(
|
||||
node.path("amount").decimalValue(BigDecimal.ZERO),
|
||||
node.path("currency").asString("GBP"));
|
||||
}
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
SimpleModule module = new SimpleModule("MoneyModule"); // no Version argument
|
||||
module.addSerializer(Money.class, new MoneySerializer());
|
||||
module.addDeserializer(Money.class, new MoneyDeserializer());
|
||||
|
||||
JsonMapper mapper = JsonMapper.builder().addModule(module).build();
|
||||
|
||||
System.out.println("serialised : " + mapper.writeValueAsString(new Money(new BigDecimal("19.99"), "usd")));
|
||||
System.out.println("deserialised : " + mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class));
|
||||
System.out.println("base classes : " + MoneySerializer.class.getSuperclass().getName()
|
||||
+ " / " + MoneyDeserializer.class.getSuperclass().getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-migration-guide/ (Step 5)
|
||||
*
|
||||
* The entire "before" setup collapses to JsonMapper.builder().build().
|
||||
*
|
||||
* Deleted, and not replaceable — these Jackson 2 classes have no tools.jackson
|
||||
* equivalent, so leaving the registerModule calls in place is a compile error:
|
||||
* - JavaTimeModule (java.time is built in)
|
||||
* - Jdk8Module (Optional is built in)
|
||||
* - ParameterNamesModule (records use RecordComponent reflection)
|
||||
* - the -parameters compiler flag (see this module's pom.xml — there isn't one)
|
||||
* - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) (constant does not
|
||||
* exist on SerializationFeature; ISO-8601 is already the default)
|
||||
*/
|
||||
public class S04ModulesAndRecords {
|
||||
|
||||
public record TravelPlan(Long id, LocalDate departure, Optional<String> seat) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
JsonMapper mapper = JsonMapper.builder().build(); // that is the whole setup
|
||||
|
||||
TravelPlan plan = new TravelPlan(1L, LocalDate.of(2026, 9, 15), Optional.of("12A"));
|
||||
String json = mapper.writeValueAsString(plan);
|
||||
System.out.println("no modules : " + json);
|
||||
System.out.println("round-trip : " + mapper.readValue(json, TravelPlan.class));
|
||||
System.out.println("dates : "
|
||||
+ mapper.writeValueAsString(java.util.Map.of("d", LocalDate.of(2026, 9, 15))));
|
||||
System.out.println("empty Optional : "
|
||||
+ mapper.writeValueAsString(new TravelPlan(2L, LocalDate.of(2026, 9, 16), Optional.empty())));
|
||||
|
||||
// Byte-identical to the Jackson 2 output from before/S04ModulesAndRecords —
|
||||
// the wire format did not change, only the API did.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import tools.jackson.databind.DefaultTyping;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Posts: https://ankurm.com/jackson-security-best-practices/
|
||||
* and https://ankurm.com/jackson-3-migration-guide/ (Step 4)
|
||||
*
|
||||
* Three migration points the posts do not state:
|
||||
* 1. activateDefaultTyping is NOT on the Jackson 3 mapper either. It lives only
|
||||
* on JsonMapper.Builder. The security post's `mapper.activateDefaultTyping(...)`
|
||||
* snippet does not compile against Jackson 3.
|
||||
* 2. ObjectMapper.DefaultTyping is now a top-level enum, tools.jackson.databind.DefaultTyping.
|
||||
* 3. With NON_FINAL, the ROOT type also gets a type id, so the root class must be
|
||||
* allowlisted too — otherwise the happy path fails, not just the attack path.
|
||||
*/
|
||||
public class S05DefaultTyping {
|
||||
|
||||
public abstract static class BasePayload { }
|
||||
public static class SafePayload extends BasePayload {
|
||||
public String note;
|
||||
public SafePayload() { }
|
||||
public SafePayload(String n) { note = n; }
|
||||
@Override public String toString() { return "SafePayload[" + note + "]"; }
|
||||
}
|
||||
public static class RoguePayload { public String note; }
|
||||
public static class Envelope {
|
||||
public Object body;
|
||||
public Envelope() { }
|
||||
public Envelope(Object b) { body = b; }
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
for (String name : new String[] { "enableDefaultTyping", "activateDefaultTyping" }) {
|
||||
System.out.printf("%-22s on Jackson 3 ObjectMapper : %s%n", name,
|
||||
java.util.Arrays.stream(ObjectMapper.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals(name)));
|
||||
}
|
||||
System.out.println("activateDefaultTyping on JsonMapper.Builder : "
|
||||
+ java.util.Arrays.stream(JsonMapper.Builder.class.getMethods())
|
||||
.anyMatch(m -> m.getName().equals("activateDefaultTyping")));
|
||||
System.out.println();
|
||||
|
||||
PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder()
|
||||
.allowIfSubType(Envelope.class) // root needs allowlisting too
|
||||
.allowIfSubType(BasePayload.class)
|
||||
.build();
|
||||
|
||||
JsonMapper mapper = JsonMapper.builder()
|
||||
.activateDefaultTyping(validator, // builder method
|
||||
DefaultTyping.NON_FINAL, // top-level enum
|
||||
JsonTypeInfo.As.PROPERTY)
|
||||
.build();
|
||||
|
||||
String json = mapper.writeValueAsString(new Envelope(new SafePayload("ok")));
|
||||
System.out.println("written : " + json);
|
||||
System.out.println("read : " + ((Envelope) mapper.readValue(json, Envelope.class)).body);
|
||||
|
||||
String rogue = "{\"@class\":\"" + Envelope.class.getName() + "\",\"body\":[\""
|
||||
+ RoguePayload.class.getName() + "\",{\"note\":\"pwn\"}]}";
|
||||
try {
|
||||
mapper.readValue(rogue, Envelope.class);
|
||||
System.out.println("rogue : UNEXPECTEDLY ACCEPTED");
|
||||
} catch (Exception e) {
|
||||
System.out.println("rogue : rejected with " + e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ankurm.migration.after;
|
||||
|
||||
import tools.jackson.databind.DeserializationFeature;
|
||||
import tools.jackson.databind.MapperFeature;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
/**
|
||||
* AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/
|
||||
* Section: "Removed and Changed Default Features"
|
||||
*
|
||||
* Same probes as before/S07DefaultsThatFlipped. Diff the two output files:
|
||||
*
|
||||
* diff docs/output/before-S07DefaultsThatFlipped.txt \
|
||||
* docs/output/after-S07DefaultsThatFlipped.txt
|
||||
*
|
||||
* Everything that differs is a silent behavioural change waiting in your upgrade.
|
||||
*/
|
||||
public class S07DefaultsThatFlipped {
|
||||
|
||||
public record OrderDto(Long orderId) { }
|
||||
|
||||
public static void main(String[] args) {
|
||||
JsonMapper mapper = JsonMapper.builder().build();
|
||||
|
||||
System.out.println("FAIL_ON_TRAILING_TOKENS : "
|
||||
+ mapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
|
||||
System.out.println("FAIL_ON_UNKNOWN_PROPERTIES : "
|
||||
+ mapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES));
|
||||
System.out.println("ALLOW_FINAL_FIELDS_AS_MUTATORS: "
|
||||
+ mapper.serializationConfig().isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS));
|
||||
System.out.println("DEFAULT_VIEW_INCLUSION : "
|
||||
+ mapper.serializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION));
|
||||
System.out.println("AUTO_DETECT_CREATORS exists : "
|
||||
+ java.util.Arrays.stream(MapperFeature.values())
|
||||
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS")));
|
||||
System.out.println();
|
||||
|
||||
System.out.println("concatenated JSON -> " + read(mapper, "{\"orderId\":1} {\"orderId\":2}"));
|
||||
System.out.println("trailing garbage -> " + read(mapper, "{\"orderId\":1}garbage"));
|
||||
System.out.println("unknown property -> " + read(mapper, "{\"orderId\":1,\"nope\":2}"));
|
||||
}
|
||||
|
||||
private static String read(JsonMapper mapper, String json) {
|
||||
try {
|
||||
return "accepted: " + mapper.readValue(json, OrderDto.class);
|
||||
} catch (Exception e) {
|
||||
return "rejected: " + e.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user