1
0

Jackson 3 series companion code

37 runnable examples covering the eight feature posts on ankurm.com, verified
against Jackson 3.2.1 on Temurin 21.0.5. Every output committed under docs/ was
produced by run-all.sh.

Also documents 11 places where the published snippets do not compile or do not
behave as printed against a real Jackson 3 build - most notably that
writeValueAsString(List<Base>) silently drops the polymorphic type discriminator,
so the post's serialised output cannot be read back.
This commit is contained in:
2026-08-04 23:12:11 +05:30
commit c438afc33b
94 changed files with 3387 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.StringReader;
import java.util.List;
import java.util.stream.Stream;
/**
* BEYOND THE POSTS — the change most likely to reach production unnoticed.
*
* In Jackson 2, JsonProcessingException extended IOException, so `catch (IOException)`
* caught mapping failures. In Jackson 3, JacksonException extends RuntimeException.
*
* There are two cases, and only one of them is safe:
*
* A. The try block contains ONLY Jackson calls. `catch (IOException)` becomes a
* COMPILE ERROR — "exception java.io.IOException is never thrown in body of
* corresponding try statement". The compiler saves you. Good.
*
* B. The try block also contains real I/O — reading a file, a socket, a request
* body. IOException is still thrown by that code, so the catch block compiles
* fine and simply stops catching the Jackson half. This is the dangerous case,
* and it is by far the more common shape in real code.
*
* Case B is what runs below.
*/
public class Y01UncheckedExceptions {
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));
System.out.println();
JsonMapper mapper = JsonMapper.builder().build();
String malformed = "{\"orderId\": not-json}";
// CASE B: genuine I/O plus a Jackson call in the same try block. Compiles
// cleanly, catches the I/O half, and lets the Jackson half straight through.
System.out.println("-- catch (IOException) around I/O + Jackson --");
try {
try (StringReader reader = new StringReader(malformed)) {
reader.read(); // makes IOException reachable
mapper.readValue(malformed, OrderDto.class); // no longer covered
System.out.println(" unreachable");
} catch (IOException e) {
System.out.println(" caught by IOException handler");
}
} catch (JacksonException escaped) {
System.out.println(" ESCAPED the IOException handler -> "
+ escaped.getClass().getSimpleName());
}
// The correct Jackson 3 idiom: catch both, separately.
System.out.println("-- catch (JacksonException) then catch (IOException) --");
try (StringReader reader = new StringReader(malformed)) {
reader.read();
mapper.readValue(malformed, OrderDto.class);
} catch (JacksonException e) {
System.out.println(" caught: " + e.getClass().getSimpleName());
} catch (IOException e) {
System.out.println(" I/O error: " + e.getClass().getSimpleName());
}
// The upside of unchecked: Jackson calls now compose inside lambdas without a
// wrapper, which was genuinely painful in Jackson 2.
System.out.println("-- unchecked exceptions inside a stream --");
List<String> json = Stream.of(new OrderDto(1L, "Alice"), new OrderDto(2L, "Bob"))
.map(mapper::writeValueAsString) // no try/catch, no helper needed
.toList();
System.out.println(" " + json);
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POSTS — a default that flipped between Jackson 2 and Jackson 3.
*
* FAIL_ON_TRAILING_TOKENS was OFF in Jackson 2 and is ON in Jackson 3. Concatenated
* or double-encoded JSON that Jackson 2 quietly accepted (reading the first document
* and discarding the rest) now throws. This is a correctness improvement, but it will
* surface as new runtime failures on payloads that used to "work".
*/
public class Y02TrailingTokens {
public record OrderDto(Long orderId) { }
public static void main(String[] args) {
JsonMapper strict = JsonMapper.builder().build(); // Jackson 3 default
JsonMapper relaxed = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) // Jackson 2 behaviour
.build();
System.out.println("FAIL_ON_TRAILING_TOKENS default : "
+ strict.deserializationConfig().isEnabled(
DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
System.out.println();
String concatenated = "{\"orderId\":1} {\"orderId\":2}";
System.out.println("Jackson 3 default -> " + read(strict, concatenated));
System.out.println("2.x behaviour -> " + read(relaxed, concatenated));
String trailingGarbage = "{\"orderId\":1}garbage";
System.out.println("garbage, default -> " + read(strict, trailingGarbage));
System.out.println("garbage, relaxed -> " + read(relaxed, trailingGarbage));
}
private static String read(JsonMapper mapper, String json) {
try {
return "accepted: " + mapper.readValue(json, OrderDto.class);
} catch (Exception e) {
return "rejected: " + e.getClass().getSimpleName();
}
}
}

View File

@@ -0,0 +1,62 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.databind.cfg.DateTimeFeature;
import tools.jackson.databind.json.JsonMapper;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
/**
* BEYOND THE POSTS — where WRITE_DATES_AS_TIMESTAMPS actually lives.
*
* Several of the blog snippets carry `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`
* over from Jackson 2. That constant does NOT exist on SerializationFeature in Jackson 3;
* the code does not compile. The flag moved to tools.jackson.databind.cfg.DateTimeFeature
* and, more usefully, it now defaults to OFF — so ISO-8601 output needs no configuration
* at all and there is nothing to disable.
*/
public class Y03DateTimeDefaults {
public record Meeting(LocalDate day, LocalDateTime startsAt,
Instant recordedAt, ZonedDateTime zoned, Duration length) { }
public static void main(String[] args) {
System.out.println("SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : "
+ hasSerializationFeature("WRITE_DATES_AS_TIMESTAMPS"));
System.out.println("DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : "
+ JsonMapper.builder().build().serializationConfig()
.isEnabled(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS));
System.out.println();
Meeting meeting = new Meeting(
LocalDate.of(2026, 9, 15),
LocalDateTime.of(2026, 9, 15, 10, 30),
Instant.parse("2026-09-15T10:30:00Z"),
ZonedDateTime.parse("2026-09-15T10:30:00Z"),
Duration.ofMinutes(45));
JsonMapper defaults = JsonMapper.builder().build();
System.out.println("defaults (ISO-8601):");
System.out.println(" " + defaults.writeValueAsString(meeting));
JsonMapper timestamps = JsonMapper.builder()
.enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();
System.out.println("with WRITE_DATES_AS_TIMESTAMPS enabled:");
System.out.println(" " + timestamps.writeValueAsString(meeting));
// Both forms read back, so an upgrade does not break existing stored payloads.
String numeric = timestamps.writeValueAsString(meeting);
System.out.println("numeric form reads back: " + defaults.readValue(numeric, Meeting.class).day());
}
private static boolean hasSerializationFeature(String name) {
for (var f : tools.jackson.databind.SerializationFeature.values()) {
if (f.name().equals(name)) return true;
}
return false;
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.jackson3.beyond;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.PropertyNamingStrategies;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POSTS — how to vary configuration once the mapper is immutable.
*
* The Jackson 2 habit of grabbing the shared mapper and calling configure() on it is
* impossible in Jackson 3 — there are no mutators. The replacements are rebuild(),
* which forks a builder from an existing mapper, and reader()/writer() views for
* per-call tweaks. Neither disturbs the shared instance.
*/
public class Y04ImmutableMapperAndReaders {
public record UserProfile(String firstName, String lastName, String middleName) { }
public static void main(String[] args) {
JsonMapper shared = JsonMapper.builder().build();
UserProfile user = new UserProfile("Ada", "Lovelace", null);
System.out.println("shared : " + shared.writeValueAsString(user));
// 1. rebuild(): fork the shared mapper's configuration and change one thing.
JsonMapper snakeCase = shared.rebuild()
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.changeDefaultPropertyInclusion(i -> i.withValueInclusion(JsonInclude.Include.NON_NULL))
.build();
System.out.println("rebuilt snake_case : " + snakeCase.writeValueAsString(user));
// 2. The shared mapper is untouched by the fork.
System.out.println("shared unchanged : " + shared.writeValueAsString(user));
// 3. writer()/reader() views for a single call, no new mapper needed.
System.out.println("writer view pretty : "
+ shared.writer().withDefaultPrettyPrinter().writeValueAsString(user)
.replace("\n", " ").replaceAll("\\s+", " "));
System.out.println("reader view strict : " + readStrict(shared));
}
private static String readStrict(JsonMapper shared) {
String json = "{\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"unexpected\":1}";
try {
shared.reader()
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.forType(UserProfile.class) // ObjectReader has no readValue(String, Class)
.readValue(json);
return "accepted";
} catch (Exception e) {
return "rejected (" + e.getClass().getSimpleName() + ") without touching the shared mapper";
}
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.jackson3.beyond;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import tools.jackson.databind.MapperFeature;
import tools.jackson.databind.json.JsonMapper;
import java.util.Arrays;
/**
* BEYOND THE POSTS — checking a claim rather than repeating it.
*
* The comparison post states that MapperFeature.AUTO_DETECT_CREATORS was removed and
* that, as a result, "any class relying on a single-argument constructor being detected
* without an annotation will quietly fail."
*
* Half right. The enum constant is genuinely gone. But the BEHAVIOUR it controlled is
* still there: a single-argument constructor is still detected as a delegating creator.
* The wrapper below deserialises with no @JsonCreator at all. Annotate anyway — it is
* explicit and free — but do not expect the upgrade to break these classes.
*/
public class Y05CreatorDetection {
/** No @JsonCreator anywhere. */
public static class ImplicitOrderId {
private final String value;
public ImplicitOrderId(String value) { this.value = value; }
@JsonValue public String value() { return value; }
@Override public String toString() { return "ImplicitOrderId[" + value + "]"; }
}
/** The explicit form, which is what you should write. */
public static class ExplicitOrderId {
private final String value;
@JsonCreator public ExplicitOrderId(String value) { this.value = value; }
@JsonValue public String value() { return value; }
@Override public String toString() { return "ExplicitOrderId[" + value + "]"; }
}
public static void main(String[] args) {
boolean present = Arrays.stream(MapperFeature.values())
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"));
System.out.println("MapperFeature.AUTO_DETECT_CREATORS exists : " + present);
System.out.println("Nearest surviving features : "
+ Arrays.stream(MapperFeature.values())
.filter(f -> f.name().contains("CREATOR") || f.name().contains("PARAMETER_NAMES"))
.map(Enum::name).toList());
System.out.println();
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("implicit single-arg ctor : " + mapper.readValue("\"ord-1\"", ImplicitOrderId.class));
System.out.println("explicit @JsonCreator : " + mapper.readValue("\"ord-2\"", ExplicitOrderId.class));
System.out.println("round-trip via @JsonValue: " + mapper.writeValueAsString(new ImplicitOrderId("ord-3")));
}
}

View File

@@ -0,0 +1,69 @@
package com.ankurm.jackson3.beyond;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.util.JsonRecyclerPools;
import tools.jackson.core.util.RecyclerPool;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* BEYOND THE POSTS — the performance knob the comparison post names but never measures.
*
* Jackson 3 changed the default buffer RecyclerPool. The post says to restore the 2.x
* thread-local pool if you see a regression. Whether that helps depends entirely on your
* concurrency profile, so this measures it on the machine you are actually running on
* instead of asserting a winner.
*
* Indicative timings, not JMH. Run it a few times; the numbers move.
*/
public class Y06RecyclerPoolTuning {
public record Payload(long id, String name, List<String> tags, double amount) { }
private static final int ITERATIONS = 40_000;
public static void main(String[] args) throws Exception {
System.out.println("default pool : "
+ JsonRecyclerPools.defaultPool().getClass().getSimpleName());
System.out.println("cores : " + Runtime.getRuntime().availableProcessors());
System.out.println();
for (int threads : new int[] { 1, 8 }) {
System.out.println("--- " + threads + " thread(s), " + ITERATIONS + " round-trips each ---");
run(threads, "threadLocalPool (2.x default)", JsonRecyclerPools.threadLocalPool());
run(threads, "concurrentDeque (3.x default)", JsonRecyclerPools.newConcurrentDequePool());
run(threads, "nonRecyclingPool (no reuse) ", JsonRecyclerPools.nonRecyclingPool());
System.out.println();
}
}
private static void run(int threads, String label, RecyclerPool<?> pool) throws Exception {
@SuppressWarnings({ "unchecked", "rawtypes" })
JsonFactory factory = JsonFactory.builder().recyclerPool((RecyclerPool) pool).build();
JsonMapper mapper = JsonMapper.builder(factory).build();
Payload sample = new Payload(1L, "example", List.of("a", "b", "c"), 12.5);
for (int i = 0; i < 2_000; i++) { // warm up
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
}
ExecutorService pooled = Executors.newFixedThreadPool(threads);
long start = System.nanoTime();
var tasks = java.util.stream.IntStream.range(0, threads)
.<Callable<Void>>mapToObj(t -> () -> {
for (int i = 0; i < ITERATIONS; i++) {
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
}
return null;
}).toList();
for (var future : pooled.invokeAll(tasks)) future.get();
long ms = (System.nanoTime() - start) / 1_000_000;
pooled.shutdown();
System.out.printf(" %-34s %5d ms%n", label, ms);
}
}

View File

@@ -0,0 +1,49 @@
package com.ankurm.jackson3.part0setup;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
* Section: "Your First Serialise/Deserialise Example"
*
* The simplest possible Jackson 3 round-trip: a POJO out to JSON and back.
*/
public class A01FirstRoundTrip {
/** A plain POJO with getters and setters — the classic Jackson shape. */
public static class ProductSummary {
private Long productId;
private String productName;
private double listPrice;
public ProductSummary() { } // needed for deserialisation
public ProductSummary(Long id, String name, double price) {
this.productId = id; this.productName = name; this.listPrice = price;
}
public Long getProductId() { return productId; }
public String getProductName() { return productName; }
public double getListPrice() { return listPrice; }
public void setProductId(Long v) { this.productId = v; }
public void setProductName(String v) { this.productName = v; }
public void setListPrice(double v) { this.listPrice = v; }
}
public static void main(String[] args) {
// Jackson 3: JsonMapper.builder().build() replaces `new ObjectMapper()`.
// The result is IMMUTABLE — you cannot reconfigure it afterwards.
JsonMapper mapper = JsonMapper.builder().build();
// Serialise: Java object -> JSON string
ProductSummary product = new ProductSummary(1L, "Mechanical Keyboard", 79.99);
String jsonOutput = mapper.writeValueAsString(product);
System.out.println(jsonOutput);
// Deserialise: JSON string -> Java object
ProductSummary restored = mapper.readValue(jsonOutput, ProductSummary.class);
System.out.println(restored.getProductName());
// Note: no `throws` clause anywhere in this method. In Jackson 3 the
// exception hierarchy is rooted at JacksonException extends RuntimeException,
// so serialisation failures are UNCHECKED. See beyond/Y01UncheckedExceptions.
}
}

View File

@@ -0,0 +1,64 @@
package com.ankurm.jackson3.part0setup;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
import java.time.LocalDate;
/**
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
* Section: "The ObjectMapper Lifecycle — The One Rule That Matters Most"
*
* Build the mapper ONCE at startup and share it. In Jackson 3 this is enforced
* by the API rather than by convention: the built mapper has no setters at all.
*/
public class A02SharedMapperConfiguration {
public record Invoice(Long invoiceId, String customerName, LocalDate issuedOn, String note) { }
/**
* The Jackson 3 equivalent of the classic Spring @Bean ObjectMapper.
*
* Three of the four settings people habitually copy from Jackson 2 tutorials
* are unnecessary or wrong in Jackson 3 — the comments say which and why.
*/
static JsonMapper buildSharedMapper() {
return JsonMapper.builder()
// NOT NEEDED: FAIL_ON_UNKNOWN_PROPERTIES already defaults to false in
// Jackson 3. Listed here only because it is the single most-copied line
// from Jackson 2 configuration; deleting it changes nothing.
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
// NOT NEEDED: java.time support is built into jackson-databind 3.x and
// dates already serialise as ISO-8601 strings. There is no
// SerializationFeature.WRITE_DATES_AS_TIMESTAMPS in Jackson 3 — the flag
// moved to tools.jackson.databind.cfg.DateTimeFeature and is off by
// default. See beyond/Y03DateTimeDefaults for proof.
// Skip null fields. Jackson 2's setSerializationInclusion(...) and the
// builder's serializationInclusion(...) do NOT exist in Jackson 3.
// The real API is changeDefaultPropertyInclusion.
.changeDefaultPropertyInclusion(
incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
.build();
}
public static void main(String[] args) {
JsonMapper mapper = buildSharedMapper();
Invoice invoice = new Invoice(500L, "Alice", LocalDate.of(2026, 4, 9), null);
System.out.println("configured : " + mapper.writeValueAsString(invoice));
JsonMapper plain = JsonMapper.builder().build();
System.out.println("defaults : " + plain.writeValueAsString(invoice));
// Proof that the mapper is immutable: ObjectMapper in Jackson 3 exposes no
// set*() mutators at all, so there is no way to reconfigure a shared instance.
long setterCount = java.util.Arrays.stream(
tools.jackson.databind.ObjectMapper.class.getMethods())
.filter(m -> m.getName().startsWith("set"))
.count();
System.out.println("ObjectMapper set*() methods in Jackson 3: " + setterCount);
}
}

View File

@@ -0,0 +1,45 @@
package com.ankurm.jackson3.part0setup;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
* Section: "Jackson's Three Processing Models"
*
* The same payload read three ways, so the trade-off is concrete rather than a table.
*/
public class A03ThreeProcessingModels {
public record Order(Long orderId, String status) { }
private static final String JSON = "{\"orderId\":1001,\"status\":\"SHIPPED\"}";
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
// 1. DATA BINDING — the right answer roughly 95% of the time.
Order bound = mapper.readValue(JSON, Order.class);
System.out.println("1. data binding : " + bound);
// 2. TREE MODEL — schema not known at compile time; navigate a JsonNode.
JsonNode tree = mapper.readTree(JSON);
System.out.println("2. tree model : orderId=" + tree.path("orderId").asInt()
+ " status=" + tree.path("status").asString());
// 3. STREAMING — token by token, constant memory, no document ever built.
StringBuilder streamed = new StringBuilder();
try (JsonParser parser = mapper.createParser(JSON)) {
while (parser.nextToken() != null) {
if (parser.currentToken() == JsonToken.PROPERTY_NAME) {
String field = parser.currentName();
parser.nextToken(); // advance to the value
streamed.append(field).append('=').append(parser.getString()).append(' ');
}
}
}
System.out.println("3. streaming : " + streamed.toString().trim());
}
}

View File

@@ -0,0 +1,39 @@
package com.ankurm.jackson3.part1objectmapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
/**
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
* Section: "Serialising Java Objects to JSON"
*
* Every write target: String, File, and pretty-printed String.
*/
public class B01WriteJson {
public record Article(Long articleId, String title, List<String> tags) { }
public static void main(String[] args) throws Exception {
JsonMapper mapper = JsonMapper.builder().build();
Article article = new Article(1L, "Jackson Deep Dive", List.of("java", "json"));
// 1. Write to a String
String jsonOutput = mapper.writeValueAsString(article);
System.out.println(jsonOutput);
// 2. Write to a File
File target = File.createTempFile("article", ".json");
mapper.writeValue(target, article);
System.out.println("file : " + Files.readString(target.toPath()));
// 3. Pretty-printed output
String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(article);
System.out.println("pretty :");
System.out.println(pretty);
target.deleteOnExit();
}
}

View File

@@ -0,0 +1,49 @@
package com.ankurm.jackson3.part1objectmapper;
import tools.jackson.databind.json.JsonMapper;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
/**
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
* Section: "Deserialising JSON to Java Objects"
*
* Reading from a String, a File and an InputStream.
*
* The post also shows readValue(new URL(...), ...). That overload is deliberately
* NOT reproduced here: it performs a live network call, which would make this
* example non-reproducible. The InputStream form below is what a real HTTP client
* hands you anyway.
*/
public class B02ReadJson {
public record Article(Long articleId, String title, List<String> tags) { }
public static void main(String[] args) throws Exception {
JsonMapper mapper = JsonMapper.builder().build();
String json = "{\"articleId\":1,\"title\":\"Jackson Deep Dive\",\"tags\":[\"java\",\"json\"]}";
// 1. Read from a String
Article fromString = mapper.readValue(json, Article.class);
System.out.println("from String : " + fromString.title());
// 2. Read from a File
File file = File.createTempFile("article", ".json");
Files.writeString(file.toPath(), json);
Article fromFile = mapper.readValue(file, Article.class);
System.out.println("from File : " + fromFile.articleId());
// 3. Read from an InputStream
try (var in = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))) {
Article fromStream = mapper.readValue(in, Article.class);
System.out.println("from Stream : " + fromStream.tags());
}
file.deleteOnExit();
}
}

View File

@@ -0,0 +1,54 @@
package com.ankurm.jackson3.part1objectmapper;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
import java.util.Map;
/**
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
* Section: "Working with Collections and Generic Types"
*
* Why TypeReference is required, and what actually happens without it.
*
* Note the import: TypeReference lives in tools.jackson.core.type in Jackson 3.
*/
public class B03GenericCollections {
public record Article(Long articleId, String title) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
String jsonArray = "[{\"articleId\":1,\"title\":\"First\"},"
+ "{\"articleId\":2,\"title\":\"Second\"}]";
// CORRECT: TypeReference captures List<Article> through an anonymous subclass,
// so the parameterised type survives erasure and reaches Jackson at runtime.
List<Article> articles = mapper.readValue(jsonArray, new TypeReference<List<Article>>() { });
System.out.println("size : " + articles.size());
System.out.println("element class : " + articles.get(0).getClass().getSimpleName());
System.out.println("first title : " + articles.get(0).title());
// WRONG: List.class erases the element type. This COMPILES and does not throw
// here — the failure is deferred to the first time you treat an element as an
// Article, which is what makes it such an unpleasant bug.
@SuppressWarnings("rawtypes")
List raw = mapper.readValue(jsonArray, List.class);
System.out.println("raw element : " + raw.get(0).getClass().getSimpleName()
+ " <- not Article");
try {
Article boom = (Article) raw.get(0);
System.out.println("unreachable: " + boom);
} catch (ClassCastException e) {
System.out.println("cast fails : ClassCastException, as expected");
}
// A Map value type needs the same treatment.
String jsonObject = "{\"a\":{\"articleId\":9,\"title\":\"Nine\"}}";
Map<String, Article> byKey =
mapper.readValue(jsonObject, new TypeReference<Map<String, Article>>() { });
System.out.println("map value : " + byKey.get("a").title());
}
}

View File

@@ -0,0 +1,50 @@
package com.ankurm.jackson3.part1objectmapper;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POST — no blog section covers this, but it bites on first run.
*
* A record serialises in declaration order. A getter-based POJO serialises in
* ALPHABETICAL order. If you are diffing Jackson output against a fixture, this
* is usually the reason the diff is not empty.
*/
public class B04PropertyOrdering {
/** Getter-based POJO: output is alphabetical, NOT declaration order. */
public static class ProductPojo {
private Long productId;
private String productName;
private double listPrice;
public ProductPojo(Long i, String n, double p) { productId = i; productName = n; listPrice = p; }
public Long getProductId() { return productId; }
public String getProductName() { return productName; }
public double getListPrice() { return listPrice; }
}
/** Record: output follows the component declaration order. */
public record ProductRecord(Long productId, String productName, double listPrice) { }
/** Explicit ordering wins over both defaults. */
@JsonPropertyOrder({ "productId", "productName", "listPrice" })
public static class ProductOrdered {
private final Long productId;
private final String productName;
private final double listPrice;
public ProductOrdered(Long i, String n, double p) { productId = i; productName = n; listPrice = p; }
public Long getProductId() { return productId; }
public String getProductName() { return productName; }
public double getListPrice() { return listPrice; }
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("POJO : " + mapper.writeValueAsString(
new ProductPojo(1L, "Mechanical Keyboard", 79.99)));
System.out.println("record : " + mapper.writeValueAsString(
new ProductRecord(1L, "Mechanical Keyboard", 79.99)));
System.out.println("ordered : " + mapper.writeValueAsString(
new ProductOrdered(1L, "Mechanical Keyboard", 79.99)));
}
}

View File

@@ -0,0 +1,36 @@
package com.ankurm.jackson3.part2modernjava;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson with Records, Optionals, Sealed Classes
* https://ankurm.com/jackson-java-records-optionals/
* Section: "Jackson with Java Records"
*
* Records need no module, no annotation and no -parameters compiler flag in
* Jackson 3. Check the pom: there is no jackson-module-parameter-names dependency
* and no <compilerArgs>.
*/
public class C01RecordRoundTrip {
/** A concise, immutable data transfer object. */
public record ProductRecord(Long productId, String productName, double unitPrice) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
// Serialise: Record -> JSON. Accessor methods replace getters.
ProductRecord product = new ProductRecord(101L, "Wireless Keyboard", 49.99);
String jsonOutput = mapper.writeValueAsString(product);
System.out.println(jsonOutput);
// Deserialise: JSON -> Record. The canonical constructor is located through
// the RecordComponent reflection API (Java 16+), not through parameter names.
String json = "{\"productId\":101,\"productName\":\"Wireless Keyboard\",\"unitPrice\":49.99}";
ProductRecord restored = mapper.readValue(json, ProductRecord.class);
System.out.println(restored.productName());
// Records also give you equals() for free, so a round-trip is assertable.
System.out.println("round-trip equal: " + product.equals(restored));
}
}

View File

@@ -0,0 +1,51 @@
package com.ankurm.jackson3.part2modernjava;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.json.JsonMapper;
import java.util.Optional;
/**
* Post: Jackson with Records, Optionals, Sealed Classes
* https://ankurm.com/jackson-java-records-optionals/
* Section: "Jackson with Optional<T>"
*
* Jackson 3 handles Optional natively. There is no jackson-datatype-jdk8
* dependency in the pom and no registerModule(new Jdk8Module()) call — those are
* Jackson 2 requirements, and the Jdk8Module class does not exist under
* tools.jackson at all.
*/
public class C02OptionalFields {
public record CustomerProfile(String customerName, Optional<String> middleName) { }
/** NON_ABSENT is the inclusion value that understands Optional.empty(). */
@JsonInclude(JsonInclude.Include.NON_ABSENT)
public record CompactProfile(String customerName, Optional<String> middleName) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
// Present value: the Optional is unwrapped, not wrapped in {"present":true}.
System.out.println("present : "
+ mapper.writeValueAsString(new CustomerProfile("Alice", Optional.of("Marie"))));
// Empty Optional: serialises as null by default.
System.out.println("empty : "
+ mapper.writeValueAsString(new CustomerProfile("Bob", Optional.empty())));
// NON_ABSENT omits the property entirely instead of writing null.
System.out.println("absent : "
+ mapper.writeValueAsString(new CompactProfile("Bob", Optional.empty())));
// Deserialise back.
String json = "{\"customerName\":\"Alice\",\"middleName\":\"Marie\"}";
CustomerProfile restored = mapper.readValue(json, CustomerProfile.class);
System.out.println("isPresent: " + restored.middleName().isPresent());
// A missing property deserialises to Optional.empty(), never to null.
CustomerProfile missing = mapper.readValue("{\"customerName\":\"Carol\"}", CustomerProfile.class);
System.out.println("missing -> " + missing.middleName() + " (null? "
+ (missing.middleName() == null) + ")");
}
}

View File

@@ -0,0 +1,57 @@
package com.ankurm.jackson3.part2modernjava;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
/**
* Post: Jackson with Records, Optionals, Sealed Classes
* https://ankurm.com/jackson-java-records-optionals/
* Section: "Jackson with Sealed Classes (Java 17+)"
*
* The explicit-registry form: @JsonTypeInfo plus a hand-maintained @JsonSubTypes.
* Compare with C04SealedAutoDiscovery, which drops the registry entirely.
*/
public class C03SealedWithSubTypes {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "shapeType")
@JsonSubTypes({
@JsonSubTypes.Type(value = Circle.class, name = "circle"),
@JsonSubTypes.Type(value = Rectangle.class, name = "rectangle")
})
public sealed interface Shape permits Circle, Rectangle { }
public record Circle(double radius) implements Shape { }
public record Rectangle(double width, double height) implements Shape { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
String json = "["
+ "{\"shapeType\":\"circle\",\"radius\":5.0},"
+ "{\"shapeType\":\"rectangle\",\"width\":10.0,\"height\":4.0}"
+ "]";
List<Shape> shapes = mapper.readValue(json, new TypeReference<List<Shape>>() { });
// Java 21 pattern matching for switch — exhaustive because Shape is sealed,
// so no default branch is needed and a new permitted type is a compile error.
for (Shape shape : shapes) {
String description = switch (shape) {
case Circle c -> "Circle with radius: " + c.radius();
case Rectangle r -> "Rectangle " + r.width() + " x " + r.height();
};
System.out.println(description);
}
// CAREFUL: writeValueAsString(List<Shape>) loses the discriminator, because
// the runtime type of the list carries no element type for Jackson to read.
// The result does not round-trip. See part5polymorphic/F02 for the full story.
System.out.println("lossy : " + mapper.writeValueAsString(shapes));
System.out.println("correct : " + mapper.writerFor(new TypeReference<List<Shape>>() { })
.writeValueAsString(shapes));
}
}

View File

@@ -0,0 +1,40 @@
package com.ankurm.jackson3.part2modernjava;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POST — the Jackson-3-only shortcut.
*
* Jackson 3 introspects the `permits` clause of a sealed type, so @JsonSubTypes
* can be dropped as long as each permitted type carries @JsonTypeName. That
* removes the parallel registry which, in Jackson 2, silently drifts out of sync
* with `permits` whenever someone adds a subtype.
*
* Note there is NO @JsonSubTypes anywhere in this file.
*/
public class C04SealedAutoDiscovery {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "shapeType")
public sealed interface Shape permits Circle, Rectangle, Triangle { }
@JsonTypeName("circle") public record Circle(double radius) implements Shape { }
@JsonTypeName("rectangle") public record Rectangle(double width, double height) implements Shape { }
// Added later. In Jackson 2 this line alone would break deserialisation until
// someone remembered to also add it to @JsonSubTypes. Here it just works.
@JsonTypeName("triangle") public record Triangle(double base, double height) implements Shape { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
for (Shape original : new Shape[] {
new Circle(5.0), new Rectangle(10.0, 4.0), new Triangle(3.0, 6.0) }) {
String json = mapper.writeValueAsString(original);
Shape restored = mapper.readValue(json, Shape.class);
System.out.printf("%-24s -> %-52s -> %s%n",
original.getClass().getSimpleName(), json, restored);
}
}
}

View File

@@ -0,0 +1,44 @@
package com.ankurm.jackson3.part3annotations;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
* Sections: "@JsonProperty" and "@JsonIgnore"
*
* Every @Json* annotation is imported from com.fasterxml.jackson.annotation, even
* on Jackson 3. jackson-annotations deliberately keeps the old group ID and package
* so one copy can be shared by Jackson 2 and Jackson 3 code on the same classpath.
*/
public class D01RenameAndIgnore {
public record OrderSummary(
@JsonProperty("order_id") Long orderId,
@JsonProperty("customer_name") String customerName) { }
public record UserAccount(
String username,
@JsonIgnore String passwordHash) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("rename : "
+ mapper.writeValueAsString(new OrderSummary(1001L, "Alice")));
// Deserialisation honours the renamed key in both directions.
OrderSummary back = mapper.readValue(
"{\"order_id\":1001,\"customer_name\":\"Alice\"}", OrderSummary.class);
System.out.println("read back : " + back);
System.out.println("ignore : "
+ mapper.writeValueAsString(new UserAccount("alice", "$2a$10$secret")));
// @JsonIgnore is bidirectional: the field is not read from JSON either.
UserAccount ignored = mapper.readValue(
"{\"username\":\"alice\",\"passwordHash\":\"injected\"}", UserAccount.class);
System.out.println("read back : passwordHash=" + ignored.passwordHash());
}
}

View File

@@ -0,0 +1,46 @@
package com.ankurm.jackson3.part3annotations;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonInclude;
import tools.jackson.databind.json.JsonMapper;
import java.time.LocalDate;
import java.util.List;
/**
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
* Sections: "@JsonInclude" and "@JsonFormat"
*
* One correction to the post: it says "Without @JsonFormat, Jackson writes LocalDate
* as a numeric array by default." That was true in Jackson 2. In Jackson 3, java.time
* support is built in and ISO-8601 is the default — the annotation is only needed for
* a NON-standard pattern. The `defaultDate` field below proves it.
*/
public class D02InclusionAndFormat {
@JsonInclude(JsonInclude.Include.NON_NULL)
public record ProductDetails(String productName, String productDescription, Double discountRate) { }
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public record CompactProduct(String productName, String notes, List<String> tags) { }
public record InvoiceRecord(
Long invoiceId,
LocalDate defaultDate, // no annotation
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy")
LocalDate ukStyleDate, // custom pattern
@JsonFormat(shape = JsonFormat.Shape.STRING)
double totalAmount) { } // number as string
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("NON_NULL : "
+ mapper.writeValueAsString(new ProductDetails("Keyboard", null, null)));
System.out.println("NON_EMPTY : "
+ mapper.writeValueAsString(new CompactProduct("Keyboard", "", List.of())));
System.out.println("formats : " + mapper.writeValueAsString(new InvoiceRecord(
500L, LocalDate.of(2026, 4, 9), LocalDate.of(2026, 4, 9), 199.99)));
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.jackson3.part3annotations;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
* Sections: "@JsonAlias" and "@JsonIgnoreProperties"
*
* Correction to the post: it frames @JsonIgnoreProperties(ignoreUnknown = true) as the
* per-class alternative to configuring FAIL_ON_UNKNOWN_PROPERTIES globally. In Jackson 3
* that feature is already DISABLED by default, so unknown fields are tolerated with no
* annotation at all. The annotation now matters mainly when you have deliberately turned
* strictness back ON — which is what the `strict` mapper below does.
*/
public class D03AliasAndUnknownFields {
public record SearchQuery(
@JsonAlias({ "q", "query", "search_term" }) String searchKeyword) { }
public record LenientResponse(String status, String message) { }
@JsonIgnoreProperties(ignoreUnknown = true)
public record OptedOutResponse(String status, String message) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
for (String json : new String[] {
"{\"q\":\"jackson\"}", "{\"query\":\"jackson\"}", "{\"search_term\":\"jackson\"}" }) {
System.out.println("alias " + String.format("%-24s", json)
+ " -> " + mapper.readValue(json, SearchQuery.class).searchKeyword());
}
String extra = "{\"status\":\"OK\",\"message\":\"done\",\"undocumentedField\":42}";
// Default Jackson 3 mapper: unknown fields are already ignored.
System.out.println("default mapper : " + mapper.readValue(extra, LenientResponse.class));
// A mapper with strictness deliberately re-enabled.
JsonMapper strict = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
try {
strict.readValue(extra, LenientResponse.class);
System.out.println("strict mapper : unreachable");
} catch (Exception e) {
System.out.println("strict mapper : " + e.getClass().getSimpleName() + " (as expected)");
}
// ...but the annotation opts this one class back out of the strictness.
System.out.println("strict + anno : " + strict.readValue(extra, OptedOutResponse.class));
}
}

View File

@@ -0,0 +1,71 @@
package com.ankurm.jackson3.part3annotations;
import com.fasterxml.jackson.annotation.JsonAnyGetter;
import com.fasterxml.jackson.annotation.JsonAnySetter;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import tools.jackson.databind.json.JsonMapper;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
* Sections: "@JsonCreator", plus @JsonUnwrapped which the post's table lists but
* never demonstrates. @JsonAnyGetter/@JsonAnySetter are beyond the post entirely.
*/
public class D04CreatorsAndUnwrapping {
/** Immutable class with no setters: the creator tells Jackson how to build it. */
public static class ImmutablePoint {
private final double xCoordinate;
private final double yCoordinate;
@JsonCreator
public ImmutablePoint(@JsonProperty("x") double xCoordinate,
@JsonProperty("y") double yCoordinate) {
this.xCoordinate = xCoordinate;
this.yCoordinate = yCoordinate;
}
@JsonProperty("x") public double getXCoordinate() { return xCoordinate; }
@JsonProperty("y") public double getYCoordinate() { return yCoordinate; }
@Override public String toString() {
return "ImmutablePoint(x=" + xCoordinate + ", y=" + yCoordinate + ")";
}
}
public record Address(String street, String city) { }
/** @JsonUnwrapped flattens the nested object into the parent's JSON object. */
public static class Customer {
public String customerName;
@JsonUnwrapped public Address address;
public Customer() { }
public Customer(String n, Address a) { customerName = n; address = a; }
}
/** Any unmapped properties land in a Map instead of being dropped. */
public static class FlexiblePayload {
public String knownField;
private final Map<String, Object> extras = new LinkedHashMap<>();
@JsonAnyGetter public Map<String, Object> extras() { return extras; }
@JsonAnySetter public void put(String k, Object v) { extras.put(k, v); }
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
ImmutablePoint point = mapper.readValue("{\"x\":3.5,\"y\":7.2}", ImmutablePoint.class);
System.out.println("creator : " + point);
System.out.println("round-trip: " + mapper.writeValueAsString(point));
System.out.println("unwrapped : " + mapper.writeValueAsString(
new Customer("Alice", new Address("123 Main St", "Springfield"))));
FlexiblePayload flexible = mapper.readValue(
"{\"knownField\":\"a\",\"surprise\":1,\"another\":[true,false]}", FlexiblePayload.class);
System.out.println("any-setter: " + flexible.extras());
System.out.println("any-getter: " + mapper.writeValueAsString(flexible));
}
}

View File

@@ -0,0 +1,38 @@
package com.ankurm.jackson3.part4custom;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ser.std.StdSerializer;
import java.math.RoundingMode;
/**
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
* Section: "Writing a Custom Serialiser"
*
* Three Jackson 3 differences from the code in the post:
* 1. Package is tools.jackson.databind.ser.std, not com.fasterxml.jackson...
* 2. The third parameter is SerializationContext, not SerializerProvider.
* 3. There is no `throws IOException` — JacksonException is unchecked in Jackson 3.
*
* (StdSerializer still exists under its old name; only JsonSerializer was renamed,
* to ValueSerializer. StdSerializer extends ValueSerializer.)
*/
public class E01MoneyValueSerializer extends StdSerializer<Money> {
public E01MoneyValueSerializer() {
super(Money.class);
}
@Override
public void serialize(Money moneyValue, JsonGenerator jsonGenerator, SerializationContext ctxt) {
jsonGenerator.writeStartObject();
// Write the amount rounded to 2 decimal places
jsonGenerator.writeNumberProperty("amount",
moneyValue.amount().setScale(2, RoundingMode.HALF_UP));
// Write the ISO currency code in uppercase
jsonGenerator.writeStringProperty("currency",
moneyValue.currencyCode().toUpperCase());
jsonGenerator.writeEndObject();
}
}

View File

@@ -0,0 +1,35 @@
package com.ankurm.jackson3.part4custom;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.deser.std.StdDeserializer;
import java.math.BigDecimal;
/**
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
* Section: "Writing a Custom Deserialiser"
*
* Jackson 3 differences from the post's code:
* 1. jsonParser.getCodec().readTree(jsonParser) is gone. Use ctxt.readTree(parser).
* 2. No `throws IOException` — JacksonException is unchecked.
* 3. path() rather than get(), so a missing field yields a MissingNode instead of
* a NullPointerException. The post's version NPEs on {"currency":"USD"}.
* 4. A bare decimalValue() on a MissingNode THROWS in Jackson 3 (it returned
* BigDecimal.ZERO in Jackson 2). Use the defaulting overload.
*/
public class E02MoneyValueDeserializer extends StdDeserializer<Money> {
public E02MoneyValueDeserializer() {
super(Money.class);
}
@Override
public Money deserialize(JsonParser jsonParser, DeserializationContext ctxt) {
JsonNode rootNode = ctxt.readTree(jsonParser);
BigDecimal amount = rootNode.path("amount").decimalValue(BigDecimal.ZERO);
String currency = rootNode.path("currency").asString("GBP"); // default when absent
return new Money(amount, currency);
}
}

View File

@@ -0,0 +1,57 @@
package com.ankurm.jackson3.part4custom;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
import java.math.BigDecimal;
/**
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
* Section: "Registering the Serialiser and Deserialiser via SimpleModule"
*
* Jackson 3 differences:
* 1. SimpleModule moved to tools.jackson.databind.module.
* 2. The Version-taking constructor from the post is gone; pass just a name.
* 3. The module is attached with builder.addModule(...), not mapper.registerModule(...),
* because a built mapper is immutable.
*/
public class E03SimpleModuleRegistration {
public static void main(String[] args) {
SimpleModule moneyModule = new SimpleModule("MoneyModule");
moneyModule.addSerializer(Money.class, new E01MoneyValueSerializer());
moneyModule.addDeserializer(Money.class, new E02MoneyValueDeserializer());
JsonMapper mapper = JsonMapper.builder()
.addModule(moneyModule)
.build();
// Serialise: note the rounding and the upper-casing done by the serialiser.
Money price = new Money(new BigDecimal("19.999"), "usd");
System.out.println("serialised : " + mapper.writeValueAsString(price));
// Deserialise. NOTE: the scale is NOT preserved by default — Jackson parses
// 20.00 as a double first, so you get 20.0 and not 20.00. The blog post claims
// 20.00; that only holds if you turn on USE_BIG_DECIMAL_FOR_FLOATS, below.
Money restored = mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class);
System.out.println("amount : " + restored.amount() + " (scale lost)");
JsonMapper exact = JsonMapper.builder()
.addModule(moneyModule)
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.build();
System.out.println("amount exact : "
+ exact.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class).amount()
+ " (scale preserved)");
// The path()-based deserialiser tolerates a missing field; the post's get()
// version would throw NullPointerException here.
Money partial = mapper.readValue("{\"currency\":\"EUR\"}", Money.class);
System.out.println("missing field: " + partial);
// Without the module the record would serialise structurally instead.
JsonMapper plain = JsonMapper.builder().build();
System.out.println("no module : " + plain.writeValueAsString(price));
}
}

View File

@@ -0,0 +1,48 @@
package com.ankurm.jackson3.part4custom;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
* Section: "Mix-in Annotations — Annotating Third-Party Classes"
*
* Jackson 3 difference: mixins are registered on the BUILDER (addMixIn), because
* mapper.addMixIn(...) does not exist on an immutable mapper.
*/
public class E04MixinAnnotations {
/** Stand-in for a third-party class whose source you cannot modify. */
public static class Address {
public String street;
public String city;
public String postalCode;
public String internalTrackingCode; // must never reach the wire
}
/** Mix-in: carries the annotations Jackson should apply to Address. */
public abstract static class AddressMixin {
@JsonIgnore public String internalTrackingCode; // suppress
@JsonProperty("zip") public String postalCode; // rename
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder()
.addMixIn(Address.class, AddressMixin.class)
.build();
Address address = new Address();
address.street = "123 Main St";
address.city = "Springfield";
address.postalCode = "12345";
address.internalTrackingCode = "INTERNAL-X99";
System.out.println("with mixin : " + mapper.writeValueAsString(address));
// The target class is untouched — a mapper without the mixin still sees
// every field under its original name.
System.out.println("without mixin: "
+ JsonMapper.builder().build().writeValueAsString(address));
}
}

View File

@@ -0,0 +1,43 @@
package com.ankurm.jackson3.part4custom;
import tools.jackson.core.JsonGenerator;
import tools.jackson.databind.SerializationContext;
import tools.jackson.databind.ValueSerializer;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.module.SimpleModule;
/**
* BEYOND THE POST — the rename that breaks every custom handler on upgrade.
*
* Jackson 2's JsonSerializer<T>/JsonDeserializer<T> are gone. The Jackson 3 names
* are ValueSerializer<T>/ValueDeserializer<T>. Extending ValueSerializer directly
* (rather than StdSerializer) is the leanest form and shows the rename plainly.
*/
public class E05ValueSerializerDirect {
public record UserId(String value) { }
/** Renders the wrapper as a bare JSON string rather than {"value":"..."}. */
static class UserIdSerializer extends ValueSerializer<UserId> {
@Override
public void serialize(UserId id, JsonGenerator gen, SerializationContext ctxt) {
gen.writeString(id.value());
}
}
public record Ticket(UserId assignee, String title) { }
public static void main(String[] args) {
SimpleModule module = new SimpleModule("UserIdModule");
module.addSerializer(UserId.class, new UserIdSerializer());
JsonMapper mapper = JsonMapper.builder().addModule(module).build();
System.out.println("custom : "
+ mapper.writeValueAsString(new Ticket(new UserId("u-42"), "Fix build")));
System.out.println("default : "
+ JsonMapper.builder().build()
.writeValueAsString(new Ticket(new UserId("u-42"), "Fix build")));
System.out.println("base class: "
+ UserIdSerializer.class.getSuperclass().getName());
}
}

View File

@@ -0,0 +1,6 @@
package com.ankurm.jackson3.part4custom;
import java.math.BigDecimal;
/** The domain value object used by the custom serialiser and deserialiser. */
public record Money(BigDecimal amount, String currencyCode) { }

View File

@@ -0,0 +1,11 @@
package com.ankurm.jackson3.part5polymorphic;
public class BankTransferPayment extends PaymentMethod {
private String bankAccountIban;
private String bankName;
public String getBankAccountIban() { return bankAccountIban; }
public String getBankName() { return bankName; }
public void setBankAccountIban(String v) { this.bankAccountIban = v; }
public void setBankName(String v) { this.bankName = v; }
}

View File

@@ -0,0 +1,11 @@
package com.ankurm.jackson3.part5polymorphic;
public class CreditCardPayment extends PaymentMethod {
private String cardNumberLastFour;
private String cardNetwork; // "VISA", "MASTERCARD", etc.
public String getCardNumberLastFour() { return cardNumberLastFour; }
public String getCardNetwork() { return cardNetwork; }
public void setCardNumberLastFour(String v) { this.cardNumberLastFour = v; }
public void setCardNetwork(String v) { this.cardNetwork = v; }
}

View File

@@ -0,0 +1,60 @@
package com.ankurm.jackson3.part5polymorphic;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "Serialising a Mixed List"
*
* CORRECTION TO THE POST. The post shows
*
* mapper.writerWithDefaultPrettyPrinter().writeValueAsString(payments)
*
* producing JSON that contains "paymentType". It does not. Passing a List to
* writeValueAsString gives Jackson only the runtime class (ImmutableCollections.ListN),
* which carries no element type, so the polymorphic type serialiser is never engaged
* and the discriminator is silently omitted. The resulting JSON then fails to
* deserialise — see F02DeserialiseMixedList.
*
* Two things do work: a typed array, or writerFor(TypeReference).
*/
public class F01SerialiseMixedList {
static List<PaymentMethod> samplePayments() {
CreditCardPayment card = new CreditCardPayment();
card.setPaymentId(1L);
card.setAmountDue(99.99);
card.setCardNumberLastFour("4242");
card.setCardNetwork("VISA");
BankTransferPayment bank = new BankTransferPayment();
bank.setPaymentId(2L);
bank.setAmountDue(250.00);
bank.setBankAccountIban("GB29NWBK60161331926819");
bank.setBankName("National Bank");
return List.of(card, bank);
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
List<PaymentMethod> payments = samplePayments();
System.out.println("--- 1. single element: discriminator present ---");
System.out.println(mapper.writeValueAsString(payments.get(0)));
System.out.println("--- 2. BROKEN: writeValueAsString(List) drops paymentType ---");
System.out.println(mapper.writeValueAsString(payments));
System.out.println("--- 3. FIX A: writerFor(TypeReference) ---");
System.out.println(mapper.writerFor(new TypeReference<List<PaymentMethod>>() { })
.withDefaultPrettyPrinter()
.writeValueAsString(payments));
System.out.println("--- 4. FIX B: a typed array carries its component type ---");
System.out.println(mapper.writeValueAsString(payments.toArray(new PaymentMethod[0])));
}
}

View File

@@ -0,0 +1,56 @@
package com.ankurm.jackson3.part5polymorphic;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
import java.util.List;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "Deserialising a Mixed List"
*
* Deserialisation is the half that works exactly as the post describes — and it is
* also what proves the serialisation defect in F01: feed it the discriminator-less
* JSON and it fails outright.
*/
public class F02DeserialiseMixedList {
private static final String GOOD_JSON = "["
+ "{\"paymentType\":\"credit_card\",\"paymentId\":1,\"amountDue\":99.99,"
+ "\"cardNumberLastFour\":\"4242\",\"cardNetwork\":\"VISA\"},"
+ "{\"paymentType\":\"bank_transfer\",\"paymentId\":2,\"amountDue\":250.0,"
+ "\"bankAccountIban\":\"GB29NWBK60161331926819\",\"bankName\":\"National Bank\"}"
+ "]";
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
List<PaymentMethod> payments =
mapper.readValue(GOOD_JSON, new TypeReference<List<PaymentMethod>>() { });
for (PaymentMethod payment : payments) {
if (payment instanceof CreditCardPayment cc) {
System.out.println("Card ending: " + cc.getCardNumberLastFour());
} else if (payment instanceof BankTransferPayment bt) {
System.out.println("Bank: " + bt.getBankName());
}
}
// Now prove the F01 defect matters: the lossy output cannot be read back.
String lossy = mapper.writeValueAsString(F01SerialiseMixedList.samplePayments());
try {
mapper.readValue(lossy, new TypeReference<List<PaymentMethod>>() { });
System.out.println("unreachable");
} catch (Exception e) {
System.out.println("lossy JSON round-trip -> " + e.getClass().getSimpleName());
}
// Whereas the correctly written output does round-trip.
String correct = mapper.writerFor(new TypeReference<List<PaymentMethod>>() { })
.writeValueAsString(F01SerialiseMixedList.samplePayments());
List<PaymentMethod> again =
mapper.readValue(correct, new TypeReference<List<PaymentMethod>>() { });
System.out.println("correct JSON round-trip -> " + again.size() + " payments, "
+ again.get(0).getClass().getSimpleName() + " first");
}
}

View File

@@ -0,0 +1,54 @@
package com.ankurm.jackson3.part5polymorphic;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "@JsonTypeInfo Placement Options"
*
* The post gives a table of the four include strategies. This runs all four so you
* can see the actual wire format instead of trusting the table.
*/
public class F03IncludeStrategies {
// ---- As.PROPERTY: discriminator is an ordinary field inside the object ----
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind")
@JsonSubTypes(@JsonSubTypes.Type(value = PropCard.class, name = "card"))
public interface PropBase { }
public record PropCard(double amountDue) implements PropBase { }
// ---- As.WRAPPER_OBJECT: object wrapped in a single-key envelope ----
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
@JsonSubTypes(@JsonSubTypes.Type(value = WrapObjCard.class, name = "card"))
public interface WrapObjBase { }
public record WrapObjCard(double amountDue) implements WrapObjBase { }
// ---- As.WRAPPER_ARRAY: two-element [name, object] array ----
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_ARRAY)
@JsonSubTypes(@JsonSubTypes.Type(value = WrapArrCard.class, name = "card"))
public interface WrapArrBase { }
public record WrapArrCard(double amountDue) implements WrapArrBase { }
// ---- As.EXISTING_PROPERTY: reuses a field the class already declares ----
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "kind", visible = true)
@JsonSubTypes(@JsonSubTypes.Type(value = ExistingCard.class, name = "card"))
public interface ExistingBase { String kind(); }
public record ExistingCard(String kind, double amountDue) implements ExistingBase { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("PROPERTY : " + mapper.writeValueAsString((PropBase) new PropCard(99.0)));
System.out.println("WRAPPER_OBJECT : " + mapper.writeValueAsString((WrapObjBase) new WrapObjCard(99.0)));
System.out.println("WRAPPER_ARRAY : " + mapper.writeValueAsString((WrapArrBase) new WrapArrCard(99.0)));
System.out.println("EXISTING_PROPERTY : " + mapper.writeValueAsString((ExistingBase) new ExistingCard("card", 99.0)));
// Each form reads back to the correct concrete type.
System.out.println("read PROPERTY -> " + mapper.readValue("{\"kind\":\"card\",\"amountDue\":99.0}", PropBase.class));
System.out.println("read WRAPPER_OBJECT -> " + mapper.readValue("{\"card\":{\"amountDue\":99.0}}", WrapObjBase.class));
System.out.println("read WRAPPER_ARRAY -> " + mapper.readValue("[\"card\",{\"amountDue\":99.0}]", WrapArrBase.class));
}
}

View File

@@ -0,0 +1,36 @@
package com.ankurm.jackson3.part5polymorphic;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POST — what actually happens when the discriminator is wrong.
*
* The security post asserts that an unregistered type name throws
* InvalidTypeIdException. This runs the three failure modes so the exception types
* are on the record rather than assumed: unknown name, attacker-supplied class name,
* and a missing discriminator.
*/
public class F04UnknownTypeId {
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
attempt(mapper, "unknown logical name",
"{\"paymentType\":\"crypto\",\"paymentId\":9,\"amountDue\":1.0}");
attempt(mapper, "attacker-supplied class name",
"{\"paymentType\":\"com.malicious.Gadget\",\"paymentId\":9,\"amountDue\":1.0}");
attempt(mapper, "missing discriminator",
"{\"paymentId\":9,\"amountDue\":1.0}");
}
private static void attempt(JsonMapper mapper, String label, String json) {
try {
PaymentMethod result = mapper.readValue(json, PaymentMethod.class);
System.out.printf("%-30s -> UNEXPECTEDLY OK: %s%n", label, result);
} catch (Exception e) {
System.out.printf("%-30s -> %s%n", label, e.getClass().getSimpleName());
}
}
}

View File

@@ -0,0 +1,27 @@
package com.ankurm.jackson3.part5polymorphic;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "Setting Up the Hierarchy with @JsonTypeInfo and @JsonSubTypes"
*/
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME, // use a logical name as the discriminator
include = JsonTypeInfo.As.PROPERTY, // embed it as a field in the JSON object
property = "paymentType" // the JSON key that carries the type name
)
@JsonSubTypes({
@JsonSubTypes.Type(value = CreditCardPayment.class, name = "credit_card"),
@JsonSubTypes.Type(value = BankTransferPayment.class, name = "bank_transfer")
})
public abstract class PaymentMethod {
private Long paymentId;
private double amountDue;
public Long getPaymentId() { return paymentId; }
public double getAmountDue() { return amountDue; }
public void setPaymentId(Long v) { this.paymentId = v; }
public void setAmountDue(double v) { this.amountDue = v; }
}

View File

@@ -0,0 +1,73 @@
package com.ankurm.jackson3.part6streaming;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.json.JsonFactory;
import java.io.File;
import java.nio.file.Files;
/**
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
* Section: "Reading a Large JSON Array with JsonParser"
*
* Jackson 3 differences from the post's code:
* 1. JsonFactory is in tools.jackson.core.json — NOT tools.jackson.core.
* 2. parser.getCurrentName() is now parser.currentName().
* 3. parser.getText() is now parser.getString().
* 4. JsonToken.FIELD_NAME is now JsonToken.PROPERTY_NAME.
* 5. No `throws IOException` — Jackson 3 exceptions are unchecked.
*/
public class G01StreamingParserFilter {
private static final String SAMPLE = """
[
{"level":"INFO","message":"Application started"},
{"level":"ERROR","message":"Database connection failed"},
{"level":"INFO","message":"Retrying connection"}
]
""";
public static void main(String[] args) throws Exception {
File logFile = File.createTempFile("large-logs", ".json");
Files.writeString(logFile.toPath(), SAMPLE);
logFile.deleteOnExit();
JsonFactory jsonFactory = new JsonFactory();
int errorCount = 0;
try (JsonParser parser = jsonFactory.createParser(tools.jackson.core.ObjectReadContext.empty(), logFile)) {
// Confirm the root is an array
if (parser.nextToken() != JsonToken.START_ARRAY) {
throw new IllegalStateException("Expected a JSON array at the root");
}
// Walk each element in the array
while (parser.nextToken() != JsonToken.END_ARRAY) {
String logLevel = null;
String logMessage = null;
// Walk each property inside the current object
while (parser.nextToken() != JsonToken.END_OBJECT) {
String fieldName = parser.currentName();
parser.nextToken(); // move to the value
if ("level".equals(fieldName)) {
logLevel = parser.getString();
} else if ("message".equals(fieldName)) {
logMessage = parser.getString();
}
// All other fields are skipped automatically
}
if ("ERROR".equals(logLevel)) {
System.out.println("ERROR: " + logMessage);
errorCount++;
}
}
}
System.out.println("Total errors found: " + errorCount);
}
}

View File

@@ -0,0 +1,55 @@
package com.ankurm.jackson3.part6streaming;
import tools.jackson.core.JsonEncoding;
import tools.jackson.core.JsonGenerator;
import tools.jackson.core.ObjectWriteContext;
import tools.jackson.core.json.JsonFactory;
import java.io.File;
/**
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
* Section: "Writing JSON with JsonGenerator"
*
* Jackson 3 differences: writeNumberField/writeStringField are now
* writeNumberProperty/writeStringProperty, and the factory needs an
* ObjectWriteContext. The post's 1,000,000-record loop is kept — it is the whole
* point of streaming — and the peak heap is measured so "constant memory" is a
* number rather than a claim.
*/
public class G02StreamingGenerator {
private static final int RECORD_COUNT = 1_000_000;
public static void main(String[] args) throws Exception {
File output = File.createTempFile("output", ".json");
output.deleteOnExit();
JsonFactory jsonFactory = new JsonFactory();
Runtime runtime = Runtime.getRuntime();
long before = runtime.totalMemory() - runtime.freeMemory();
long start = System.nanoTime();
try (JsonGenerator generator = jsonFactory.createGenerator(
ObjectWriteContext.empty(), output, JsonEncoding.UTF8)) {
generator.writeStartArray();
for (int recordIndex = 0; recordIndex < RECORD_COUNT; recordIndex++) {
generator.writeStartObject();
generator.writeNumberProperty("id", recordIndex);
generator.writeStringProperty("status", "active");
generator.writeEndObject();
}
generator.writeEndArray();
}
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
long after = runtime.totalMemory() - runtime.freeMemory();
System.out.println("records written : " + RECORD_COUNT);
System.out.println("file size : " + (output.length() / 1024 / 1024) + " MB");
System.out.println("elapsed : " + elapsedMs + " ms");
System.out.println("heap delta : " + ((after - before) / 1024 / 1024) + " MB"
+ " <- the document is never held in memory");
}
}

View File

@@ -0,0 +1,50 @@
package com.ankurm.jackson3.part6streaming;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
* Sections: "The Tree Model" and "Mixing Tree Model with Data Binding"
*
* Jackson 3 difference: JsonNode.asText() is now asString(). asInt() survives.
*/
public class G03TreeModelNavigation {
public record CustomerRecord(String name, String tier) { }
private static final String JSON = "{"
+ "\"orderId\":1001,"
+ "\"customer\":{\"name\":\"Alice\",\"tier\":\"gold\"},"
+ "\"items\":[{\"sku\":\"KB-01\",\"qty\":2},{\"sku\":\"MS-42\",\"qty\":1}]"
+ "}";
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
JsonNode rootNode = mapper.readTree(JSON);
// path() never returns null — a missing node is a MissingNode.
String customerName = rootNode.path("customer").path("name").asString();
System.out.println("Customer: " + customerName);
for (JsonNode itemNode : rootNode.path("items")) {
System.out.println(itemNode.path("sku").asString() + " x" + itemNode.path("qty").asInt());
}
System.out.println("Has discount: " + rootNode.has("discountCode"));
// path() vs get() on an absent field — the difference that causes NPEs.
System.out.println("path(missing) : " + rootNode.path("nope")
+ " (class " + rootNode.path("nope").getClass().getSimpleName() + ")");
System.out.println("get(missing) : " + rootNode.get("nope"));
// Deep navigation stays null-safe all the way down.
System.out.println("deep path : '"
+ rootNode.path("a").path("b").path("c").asString("<default>") + "'");
// Switch from tree to data binding at any node.
CustomerRecord customer = mapper.treeToValue(rootNode.path("customer"), CustomerRecord.class);
System.out.println("treeToValue : " + customer);
}
}

View File

@@ -0,0 +1,101 @@
package com.ankurm.jackson3.part6streaming;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.io.File;
import java.nio.file.Files;
import java.util.List;
/**
* BEYOND THE POST — the decision table, measured.
*
* The post ends with a table claiming data binding "loads full object", tree model
* "loads full tree", and streaming uses "constant" memory. This generates a real
* file and measures all three so the table has numbers behind it.
*
* These are indicative single-shot measurements on one JVM, not JMH benchmarks —
* the ordering is the point, not the absolute figures.
*/
public class G04ThreeApproachesMeasured {
public record LogEntry(long id, String level, String message) { }
private static final int ENTRIES = 200_000;
public static void main(String[] args) throws Exception {
File file = generate();
System.out.println("input file : " + (file.length() / 1024 / 1024) + " MB, "
+ ENTRIES + " entries");
System.out.println();
JsonMapper mapper = JsonMapper.builder().build();
measure("data binding (readValue)", () -> {
List<LogEntry> all = mapper.readValue(file, new TypeReference<List<LogEntry>>() { });
return all.stream().filter(e -> e.level().equals("ERROR")).count();
});
measure("tree model (readTree)", () -> {
JsonNode root = mapper.readTree(file);
long n = 0;
for (JsonNode node : root) if ("ERROR".equals(node.path("level").asString())) n++;
return n;
});
measure("streaming (JsonParser)", () -> {
long n = 0;
JsonFactory factory = new JsonFactory();
try (JsonParser p = factory.createParser(tools.jackson.core.ObjectReadContext.empty(), file)) {
p.nextToken();
while (p.nextToken() != JsonToken.END_ARRAY) {
String level = null;
while (p.nextToken() != JsonToken.END_OBJECT) {
String f = p.currentName();
p.nextToken();
if ("level".equals(f)) level = p.getString();
}
if ("ERROR".equals(level)) n++;
}
}
return n;
});
file.delete();
}
private interface Counter { long count() throws Exception; }
private static void measure(String label, Counter counter) throws Exception {
Runtime rt = Runtime.getRuntime();
System.gc();
Thread.sleep(120);
long heapBefore = rt.totalMemory() - rt.freeMemory();
long start = System.nanoTime();
long errors = counter.count();
long ms = (System.nanoTime() - start) / 1_000_000;
long heapAfter = rt.totalMemory() - rt.freeMemory();
System.out.printf("%-28s errors=%-6d %5d ms heap delta %5d MB%n",
label, errors, ms, (heapAfter - heapBefore) / 1024 / 1024);
}
private static File generate() throws Exception {
File f = File.createTempFile("logs", ".json");
f.deleteOnExit();
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < ENTRIES; i++) {
if (i > 0) sb.append(',');
sb.append("{\"id\":").append(i)
.append(",\"level\":\"").append(i % 50 == 0 ? "ERROR" : "INFO")
.append("\",\"message\":\"event number ").append(i)
.append(" with some padding to make the payload realistic\"}");
}
sb.append(']');
Files.writeString(f.toPath(), sb);
return f;
}
}

View File

@@ -0,0 +1,49 @@
package com.ankurm.jackson3.part7security;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
* Section: "Use @JsonTypeInfo Instead of Default Typing"
*
* The safe pattern: the permitted types are fixed at compile time, so no JSON payload
* can introduce a class name of its own.
*/
public class H01SafePolymorphismByAnnotation {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = EmailNotification.class, name = "email"),
@JsonSubTypes.Type(value = SmsNotification.class, name = "sms")
})
public abstract static class Notification { }
public static class EmailNotification extends Notification {
public String recipientEmail;
@Override public String toString() { return "EmailNotification[" + recipientEmail + "]"; }
}
public static class SmsNotification extends Notification {
public String recipientPhone;
@Override public String toString() { return "SmsNotification[" + recipientPhone + "]"; }
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
System.out.println("email : " + mapper.readValue(
"{\"type\":\"email\",\"recipientEmail\":\"a@example.com\"}", Notification.class));
System.out.println("sms : " + mapper.readValue(
"{\"type\":\"sms\",\"recipientPhone\":\"+441234567890\"}", Notification.class));
// A class name supplied by an attacker is not a registered logical name.
try {
mapper.readValue("{\"type\":\"com.malicious.Gadget\"}", Notification.class);
System.out.println("attack: UNEXPECTEDLY ACCEPTED");
} catch (Exception e) {
System.out.println("attack: rejected with " + e.getClass().getSimpleName());
}
}
}

View File

@@ -0,0 +1,52 @@
package com.ankurm.jackson3.part7security;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.json.JsonMapper;
import java.lang.reflect.Method;
import java.util.Arrays;
/**
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
* Section: "The Safe Alternative: PolymorphicTypeValidator"
*
* CORRECTION TO THE POST. The post shows the remediation as
*
* ObjectMapper mapper = new ObjectMapper();
* mapper.activateDefaultTyping(validator, DefaultTyping.NON_FINAL, As.PROPERTY);
*
* That is Jackson 2 code. In Jackson 3 BOTH enableDefaultTyping and
* activateDefaultTyping are absent from the mapper — the mapper has no mutators at
* all. activateDefaultTyping survives only on JsonMapper.Builder. This prints the
* reflective proof for each claim rather than asserting it.
*
* See H03PolymorphicTypeValidatorAllowlist for the working builder-based form.
*/
public class H02DefaultTypingRemoved {
public static void main(String[] args) {
System.out.println("--- tools.jackson.databind.ObjectMapper ---");
report(ObjectMapper.class, "enableDefaultTyping");
report(ObjectMapper.class, "activateDefaultTyping");
report(ObjectMapper.class, "setSerializationInclusion");
report(ObjectMapper.class, "registerModule");
report(ObjectMapper.class, "addMixIn");
System.out.println("total set*() mutators: " + Arrays.stream(ObjectMapper.class.getMethods())
.filter(m -> m.getName().startsWith("set")).count());
System.out.println();
System.out.println("--- tools.jackson.databind.json.JsonMapper.Builder ---");
report(JsonMapper.Builder.class, "activateDefaultTyping");
report(JsonMapper.Builder.class, "deactivateDefaultTyping");
report(JsonMapper.Builder.class, "polymorphicTypeValidator");
report(JsonMapper.Builder.class, "changeDefaultPropertyInclusion");
report(JsonMapper.Builder.class, "serializationInclusion");
}
private static void report(Class<?> type, String methodName) {
boolean present = Arrays.stream(type.getMethods())
.map(Method::getName)
.anyMatch(methodName::equals);
System.out.printf(" %-32s %s%n", methodName, present ? "present" : "ABSENT");
}
}

View File

@@ -0,0 +1,75 @@
package com.ankurm.jackson3.part7security;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
/**
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
* Section: "The Safe Alternative: PolymorphicTypeValidator"
*
* The post's snippet in working Jackson 3 form. Note DefaultTyping is a top-level
* enum in tools.jackson.databind, not ObjectMapper.DefaultTyping as in Jackson 2.
*
* Default typing remains a last resort. Prefer H01. This exists because legacy object
* graphs and plugin systems sometimes genuinely need it, and when they do, the
* allowlist has to be provable — hence the negative test at the bottom.
*/
public class H03PolymorphicTypeValidatorAllowlist {
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 + "]"; }
}
/** Deliberately outside the allowlisted base type. */
public static class RoguePayload {
public String note;
}
public static class Envelope {
public Object body; // the field default typing has to resolve
public Envelope() { }
public Envelope(Object b) { body = b; }
}
public static void main(String[] args) {
// Permit ONLY the envelope and our own payload hierarchy. Anything else is
// refused at type-resolution time, before any class is instantiated.
//
// Note the gotcha: with DefaultTyping.NON_FINAL, Jackson writes a type id for
// the ROOT object too, so Envelope must be allowlisted as well. Allowlisting
// only BasePayload makes even the happy path fail — which is how most people
// first meet this API.
PolymorphicTypeValidator safeTypeValidator = BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Envelope.class)
.allowIfSubType(BasePayload.class)
.build();
JsonMapper mapper = JsonMapper.builder()
.activateDefaultTyping(safeTypeValidator,
DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY)
.build();
String allowed = mapper.writeValueAsString(new Envelope(new SafePayload("ok")));
System.out.println("allowed written : " + allowed);
System.out.println("allowed read : "
+ ((Envelope) mapper.readValue(allowed, Envelope.class)).body);
// Negative test: a class outside the allowlist is rejected even though it
// exists on the classpath and would deserialise perfectly well otherwise.
String rogue = "{\"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());
}
}
}

View File

@@ -0,0 +1,57 @@
package com.ankurm.jackson3.part7security;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.databind.json.JsonMapper;
/**
* BEYOND THE POST — the hardening the security post does not mention.
*
* Gadget attacks are not the only deserialisation risk. A small payload can also
* exhaust the stack or the heap: deeply nested arrays, gigantic numbers, enormous
* strings. Jackson 3 ships StreamReadConstraints with defaults for all three, and
* they are tunable. Any service accepting external JSON should know what they are.
*/
public class H04StreamReadConstraints {
public static void main(String[] args) {
StreamReadConstraints defaults = StreamReadConstraints.defaults();
System.out.println("--- Jackson 3 defaults ---");
System.out.println("max nesting depth : " + defaults.getMaxNestingDepth());
System.out.println("max number length : " + defaults.getMaxNumberLength());
System.out.println("max string length : " + defaults.getMaxStringLength());
System.out.println("max name length : " + defaults.getMaxNameLength());
System.out.println("max doc length : " + defaults.getMaxDocumentLength()
+ " (-1 = unlimited)");
System.out.println();
JsonMapper plain = JsonMapper.builder().build();
String deep = "[".repeat(1200) + "]".repeat(1200);
System.out.println("1200-deep nesting, default limits -> " + attempt(plain, deep));
// Tighten the limits for an endpoint that should never see nested data.
JsonFactory strictFactory = JsonFactory.builder()
.streamReadConstraints(StreamReadConstraints.builder()
.maxNestingDepth(10)
.maxStringLength(2_000)
.build())
.build();
JsonMapper strict = JsonMapper.builder(strictFactory).build();
System.out.println("20-deep nesting, strict limits -> "
+ attempt(strict, "[".repeat(20) + "]".repeat(20)));
System.out.println("5-deep nesting, strict limits -> "
+ attempt(strict, "[".repeat(5) + "]".repeat(5)));
System.out.println("3KB string, strict limits -> "
+ attempt(strict, "\"" + "x".repeat(3_000) + "\""));
}
private static String attempt(JsonMapper mapper, String json) {
try {
mapper.readTree(json);
return "accepted";
} catch (Exception e) {
return "rejected (" + e.getClass().getSimpleName() + ")";
}
}
}

View File

@@ -0,0 +1,42 @@
package com.ankurm.jackson3.part7security;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
* Section: "Never Deserialise Untrusted JSON into Object.class"
*
* Worth knowing precisely what readValue(json, Object.class) does on a DEFAULT
* Jackson 3 mapper, because the answer is reassuring and often misunderstood:
* with no default typing active it produces plain Maps, Lists, Strings and numbers.
* The danger only returns when default typing is switched on — as H03 shows.
*
* The rule still stands. Target a specific type; you get validation for free.
*/
public class H05NeverDeserialiseIntoObject {
public record MyRequestDto(String action, int quantity) { }
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
String untrusted = "{\"action\":\"ship\",\"quantity\":3,\"extra\":{\"nested\":[1,2]}}";
Object loose = mapper.readValue(untrusted, Object.class);
System.out.println("as Object : " + loose);
System.out.println("runtime type: " + loose.getClass().getName()
+ " <- a plain Map, no arbitrary class was instantiated");
MyRequestDto typed = mapper.readValue(untrusted, MyRequestDto.class);
System.out.println("as DTO : " + typed);
// The real benefit of a specific target type: malformed input fails loudly
// instead of flowing onward as an untyped Map.
try {
mapper.readValue("{\"action\":\"ship\",\"quantity\":\"not-a-number\"}", MyRequestDto.class);
System.out.println("bad input : UNEXPECTEDLY ACCEPTED");
} catch (Exception e) {
System.out.println("bad input : rejected with " + e.getClass().getSimpleName());
}
}
}