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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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) + ")");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
6
src/main/java/com/ankurm/jackson3/part4custom/Money.java
Normal file
6
src/main/java/com/ankurm/jackson3/part4custom/Money.java
Normal 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) { }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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])));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() + ")";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user