commit f0a7053fc9a4fff42e0ac8f1ec94065d7fd982b8 Author: Ankur Date: Tue Aug 4 23:12:25 2026 +0530 Jackson 2 to 3 migration companion code Three Maven modules - jackson2-before (2.22.1), jackson3-after (3.2.1) and a coexistence module with BOTH majors on one classpath - so every claim in the two migration guides is executed rather than asserted. Paired class names make the before/after outputs directly diffable via run-all.sh. Confirms the guides on wire-format equivalence (10-case suite, zero mismatches), classpath coexistence and the collapse of four artifacts into one. Corrects nine points, including that enableDefaultTyping() is still present in Jackson 2.22.1 rather than removed in 2.16, and that the published "after" mapper snippet does not compile. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ebf457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +*.iml +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f85102 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# jackson2-to-3-migration + +Companion code for the two Jackson migration guides on [ankurm.com](https://ankurm.com): + +- [Jackson 2 to Jackson 3 Migration Guide](https://ankurm.com/jackson-3-migration-guide/) +- [Jackson 3 vs Jackson 2: Complete Comparison](https://ankurm.com/jackson-3-vs-jackson-2/) + +The point of this repo is that nothing here is asserted. Every migration claim is a +program that runs on **both** major versions, so you can diff the outputs. + +``` +Jackson 2 2.22.1 (com.fasterxml.jackson.core) +Jackson 3 3.2.1 (tools.jackson.core) +jackson-annotations 2.22 (shared by both) +JDK Temurin 21.0.5 +Maven 3.9.9 +``` + +## Layout + +| Module | What it is | +|---|---| +| [`jackson2-before/`](jackson2-before) | Jackson 2.22.1, with the three extra modules and the `-parameters` compiler flag it needs — see [pom.xml](jackson2-before/pom.xml) | +| [`jackson3-after/`](jackson3-after) | Jackson 3.2.1. One dependency, no modules, no compiler flags — see [pom.xml](jackson3-after/pom.xml) | +| [`coexistence/`](coexistence) | **Both majors on one classpath**, used together in one class — see [pom.xml](coexistence/pom.xml) and [docs/coexistence.md](docs/coexistence.md) | + +Paired examples share a class name across `before` and `after`, so their captured +outputs sit side by side and diff cleanly. + +## Run it + +```bash +git clone https://ankurm.com/git.app/asmhatre/jackson2-to-3-migration.git +cd jackson2-to-3-migration +./run-all.sh +``` + +`run-all.sh` builds all three modules, runs every example, writes real stdout to +`docs/output/-.txt`, and then reports which before/after pairs differ. +Diff any pair yourself: + +```bash +diff docs/output/before-S07DefaultsThatFlipped.txt \ + docs/output/after-S07DefaultsThatFlipped.txt +``` + +## The examples + +| Pair | Migration topic | Write-up | +|---|---|---| +| `S01MapperConstruction` | `new ObjectMapper()` → `JsonMapper.builder()`, and immutability | [docs/s01-mapper-construction.md](docs/s01-mapper-construction.md) | +| `S02ExceptionHierarchy` | `JsonProcessingException` (checked) → `JacksonException` (unchecked) | [docs/s02-exception-hierarchy.md](docs/s02-exception-hierarchy.md) | +| `S03CustomHandlers` | `JsonSerializer`/`JsonDeserializer` → `ValueSerializer`/`ValueDeserializer` | [docs/s03-custom-handlers.md](docs/s03-custom-handlers.md) | +| `S04ModulesAndRecords` | Four artifacts and a compiler flag collapse to one dependency | [docs/s04-modules-and-records.md](docs/s04-modules-and-records.md) | +| `S05DefaultTyping` | `activateDefaultTyping` moves to the builder | [docs/s05-default-typing.md](docs/s05-default-typing.md) | +| `S07DefaultsThatFlipped` | Defaults that changed silently | [docs/s07-defaults-that-flipped.md](docs/s07-defaults-that-flipped.md) | +| `S06BothOnOneClasspath` | Both majors in one JVM, one shared annotations jar | [docs/coexistence.md](docs/coexistence.md) | +| `S08WireFormatEquivalence` | 10-case regression suite asserting byte-identical JSON | [docs/coexistence.md](docs/coexistence.md) | + +Each write-up shows the Jackson 2 and Jackson 3 output side by side, with the source +files linked. Start at [docs/README.md](docs/README.md) for the index, or dive straight +into [docs/s07-defaults-that-flipped.md](docs/s07-defaults-that-flipped.md) — the silent +behavioural changes are the ones that cost the most to find later. Raw stdout for every +program is in [docs/output/](docs/output). + +## Corrections to the guides + +Building this against real artifacts surfaced several places where the guides are +wrong. Each is demonstrated by code. + +| # | Claim in the guide | What actually happens | +|---|---|---| +| 1 | `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)` in the "after" snippet | Does not compile. Not on `SerializationFeature` in Jackson 3 — moved to `tools.jackson.databind.cfg.DateTimeFeature`, and already off by default. See [`after/S01`](jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java). | +| 2 | `JsonMapper.builder().serializationInclusion(NON_NULL)` | No such builder method. Use `changeDefaultPropertyInclusion(UnaryOperator)`. | +| 3 | `mapper.activateDefaultTyping(validator, ObjectMapper.DefaultTyping.NON_FINAL, ...)` | Two errors: the method is not on the Jackson 3 mapper (builder only), and `DefaultTyping` is now a top-level enum. See [`after/S05`](jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java). | +| 4 | `enableDefaultTyping()` was "removed in 2.16" | Still present on `ObjectMapper` in Jackson 2.22.1, deprecated. [`before/S05`](jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java) prints the reflective proof. This matters: the method survives your 2.x upgrades and only breaks at Jackson 3. | +| 5 | `import tools.jackson.core.JsonFactory` | Wrong package — it is `tools.jackson.core.json.JsonFactory`. | +| 6 | "Java baseline: Java 11 minimum" (migration guide) | The comparison post says 17, and 17 is correct for all core modules. The migration guide's Java 11 figure is Jackson 2's. | +| 7 | `jackson-annotations` version `3.0` / `2.20.0` | Jackson 3.2.1 resolves `jackson-annotations` **2.22**. There is no released 3.0 — only release candidates. Import `tools.jackson:jackson-bom` and let it pick. | +| 8 | "`catch (IOException)` blocks silently stop catching" | Only when the block also does real I/O. If it contains nothing but Jackson calls, it is a **compile error** and the compiler catches it for you. Both cases in [`after/S02`](jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java). | +| 9 | Removing `AUTO_DETECT_CREATORS` breaks single-arg constructors | The constant is gone; the behaviour is not. Demonstrated in the [features repo](https://ankurm.com/git.app/asmhatre/jackson3-by-example). | + +## What the guides get right + +Worth stating, since the list above is all corrections. These claims were tested and hold: + +- **The wire format is identical.** `S08WireFormatEquivalence` runs 10 cases — records, + nested lists, maps, `java.time`, present and empty `Optional`, `NON_NULL`, + `@JsonProperty`, `@JsonFormat`, empty collections — through both mappers and asserts + string equality. Zero mismatches. Downstream consumers are unaffected by the upgrade. +- **Both majors coexist on one classpath.** `S06BothOnOneClasspath` uses both in one + class, and each reads the other's output. +- **Annotations do not change.** A single `jackson-annotations-2.22.jar` serves both. +- **Records, `Optional` and `java.time` need no modules in Jackson 3.** `before/S04` + shows a bare Jackson 2 mapper failing on all three; `after/S04` shows the same code + working with `JsonMapper.builder().build()` and nothing else. +- **Mapper immutability is real, not advisory.** `before/S01` mutates a shared mapper + after construction and the change sticks; `after/S01` shows `ObjectMapper` has zero + `set*` methods. + +## Related + +- [jackson3-by-example](https://ankurm.com/git.app/asmhatre/jackson3-by-example) — 37 runnable examples for the eight feature posts. diff --git a/coexistence/pom.xml b/coexistence/pom.xml new file mode 100644 index 0000000..1c27aef --- /dev/null +++ b/coexistence/pom.xml @@ -0,0 +1,61 @@ + + + 4.0.0 + + com.ankurm + jackson2-to-3-migration + 1.0.0 + + + coexistence + coexistence + Both major versions on ONE classpath — the claim, tested + + + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + + + + + + + tools.jackson.core + jackson-databind + + + com.fasterxml.jackson.core + jackson-databind + ${jackson2.version} + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson2.version} + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson2.version} + + + + + + + org.codehaus.mojo + exec-maven-plugin + + + + diff --git a/coexistence/src/main/java/com/ankurm/migration/coexist/S06BothOnOneClasspath.java b/coexistence/src/main/java/com/ankurm/migration/coexist/S06BothOnOneClasspath.java new file mode 100644 index 0000000..b778a99 --- /dev/null +++ b/coexistence/src/main/java/com/ankurm/migration/coexist/S06BothOnOneClasspath.java @@ -0,0 +1,59 @@ +package com.ankurm.migration.coexist; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.time.LocalDate; + +/** + * Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * FAQ: "Can Jackson 3 and Jackson 2 be on the same classpath?" + * + * The posts assert that the two majors coexist because the packages differ. This + * module's pom.xml puts BOTH jackson-databind artifacts on one classpath and this + * class uses both at once, so the claim is executed rather than repeated. + * + * Watch the DTO: it is annotated once, with @JsonProperty imported from + * com.fasterxml.jackson.annotation, and BOTH mappers honour it. That is the whole + * reason jackson-annotations deliberately kept the old group ID and package. + */ +public class S06BothOnOneClasspath { + + /** One DTO, one set of annotations, read by both Jackson majors. */ + public record Booking( + @JsonProperty("booking_id") Long id, + @JsonProperty("travel_date") LocalDate travelDate) { } + + public static void main(String[] args) throws Exception { + // --- Jackson 2 --- + var mapper2 = new com.fasterxml.jackson.databind.ObjectMapper(); + mapper2.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule()); + mapper2.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // --- Jackson 3 --- + var mapper3 = tools.jackson.databind.json.JsonMapper.builder().build(); + + Booking booking = new Booking(1L, LocalDate.of(2026, 9, 15)); + + String json2 = mapper2.writeValueAsString(booking); + String json3 = mapper3.writeValueAsString(booking); + + System.out.println("jackson 2 class : " + mapper2.getClass().getName()); + System.out.println("jackson 3 class : " + mapper3.getClass().getName()); + System.out.println(); + System.out.println("jackson 2 output: " + json2); + System.out.println("jackson 3 output: " + json3); + System.out.println("wire-compatible : " + json2.equals(json3)); + System.out.println(); + + // Cross-read: each major reads the other's output. + System.out.println("3 reads 2's JSON: " + mapper3.readValue(json2, Booking.class)); + System.out.println("2 reads 3's JSON: " + mapper2.readValue(json3, Booking.class)); + System.out.println(); + + // And the annotation both of them obeyed came from a single shared artifact. + System.out.println("annotation from : " + + JsonProperty.class.getProtectionDomain().getCodeSource().getLocation() + .toString().replaceAll(".*/", "")); + System.out.println(" <- one jackson-annotations jar, used by both majors"); + } +} diff --git a/coexistence/src/main/java/com/ankurm/migration/coexist/S08WireFormatEquivalence.java b/coexistence/src/main/java/com/ankurm/migration/coexist/S08WireFormatEquivalence.java new file mode 100644 index 0000000..be6f139 --- /dev/null +++ b/coexistence/src/main/java/com/ankurm/migration/coexist/S08WireFormatEquivalence.java @@ -0,0 +1,83 @@ +package com.ankurm.migration.coexist; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * The regression suite the comparison post's last AI prompt asks for, actually written. + * + * Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * FAQ: "Is Jackson 3 backward compatible with Jackson 2 at the serialised JSON level?" + * — the post answers "Yes ... identical for standard types". This checks it. + * + * Both mappers are configured as equivalently as each API allows, then fed the same + * values. Every mismatch is printed. Run this before switching a service whose output + * is cached, signed, or consumed downstream. + */ +public class S08WireFormatEquivalence { + + public record Product(Long id, String name, BigDecimal price) { } + public record Timestamps(LocalDate day, LocalDateTime at, Instant instant) { } + public record WithOptional(String name, Optional nickname) { } + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Sparse(String present, String absent) { } + public record Renamed(@JsonProperty("order_id") Long orderId) { } + public record Formatted(@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy") LocalDate d) { } + + public static void main(String[] args) throws Exception { + var mapper2 = new com.fasterxml.jackson.databind.ObjectMapper() + .registerModule(new JavaTimeModule()) + .registerModule(new Jdk8Module()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + var mapper3 = tools.jackson.databind.json.JsonMapper.builder().build(); + + record Case(String label, Object value) { } + List cases = List.of( + new Case("plain record", new Product(1L, "Keyboard", new BigDecimal("79.99"))), + new Case("nested list", List.of(new Product(1L, "A", BigDecimal.ONE), + new Product(2L, "B", BigDecimal.TEN))), + new Case("map", Map.of("k", new Product(3L, "C", BigDecimal.ZERO))), + new Case("java.time", new Timestamps(LocalDate.of(2026, 9, 15), + LocalDateTime.of(2026, 9, 15, 10, 30), + Instant.parse("2026-09-15T10:30:00Z"))), + new Case("Optional present", new WithOptional("Alice", Optional.of("Ali"))), + new Case("Optional empty", new WithOptional("Bob", Optional.empty())), + new Case("NON_NULL", new Sparse("here", null)), + new Case("@JsonProperty", new Renamed(1001L)), + new Case("@JsonFormat", new Formatted(LocalDate.of(2026, 9, 15))), + new Case("nulls and empties", Map.of("list", List.of(), "str", ""))); + + int mismatches = 0; + for (Case c : cases) { + String j2 = mapper2.writeValueAsString(c.value()); + String j3 = mapper3.writeValueAsString(c.value()); + boolean same = j2.equals(j3); + if (!same) mismatches++; + System.out.printf("%-18s %s%n", c.label(), same ? "identical" : "DIFFERS"); + if (!same) { + System.out.println(" jackson2: " + j2); + System.out.println(" jackson3: " + j3); + } + } + + System.out.println(); + System.out.println("cases : " + cases.size()); + System.out.println("mismatches: " + mismatches); + System.out.println(mismatches == 0 + ? "Wire format is identical across both majors for these cases." + : "Wire format differs — review each DIFFERS case before upgrading."); + } +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..aaaadec --- /dev/null +++ b/docs/README.md @@ -0,0 +1,15 @@ +# Documentation + +One page per migration topic, each showing the Jackson 2 and Jackson 3 output +side by side. + +- [S01 — Mapper construction and immutability](s01-mapper-construction.md) +- [S02 — The exception hierarchy](s02-exception-hierarchy.md) +- [S03 — Custom serialiser and deserialiser renames](s03-custom-handlers.md) +- [S04 — Modules, records, Optional and dates](s04-modules-and-records.md) +- [S05 — Default typing](s05-default-typing.md) +- [S07 — Defaults that flipped silently](s07-defaults-that-flipped.md) +- [S06 / S08 — Coexistence and wire-format equivalence](coexistence.md) + +Raw stdout for every program is in [`output/`](output), regenerated by +[`../run-all.sh`](../run-all.sh). diff --git a/docs/coexistence.md b/docs/coexistence.md new file mode 100644 index 0000000..36b28f1 --- /dev/null +++ b/docs/coexistence.md @@ -0,0 +1,75 @@ +# S06 / S08 — Coexistence and wire-format equivalence + +Guide: (FAQs) + +The [`coexistence/`](../coexistence) module puts **both** major versions on one +classpath. Its [`pom.xml`](../coexistence/pom.xml) declares +`tools.jackson.core:jackson-databind:3.2.1` and +`com.fasterxml.jackson.core:jackson-databind:2.22.1` together, with no exclusions and +no shading. + +## S06 — both majors in one class + +[`S06BothOnOneClasspath.java`](../coexistence/src/main/java/com/ankurm/migration/coexist/S06BothOnOneClasspath.java) + +``` +jackson 2 class : com.fasterxml.jackson.databind.ObjectMapper +jackson 3 class : tools.jackson.databind.json.JsonMapper + +jackson 2 output: {"booking_id":1,"travel_date":"2026-09-15"} +jackson 3 output: {"booking_id":1,"travel_date":"2026-09-15"} +wire-compatible : true + +3 reads 2's JSON: Booking[id=1, travelDate=2026-09-15] +2 reads 3's JSON: Booking[id=1, travelDate=2026-09-15] + +annotation from : jackson-annotations-2.22.jar + <- one jackson-annotations jar, used by both majors +``` + +One DTO, annotated once with `@JsonProperty` imported from +`com.fasterxml.jackson.annotation`, and both mappers honour it. That is precisely why +`jackson-annotations` deliberately kept the old group ID and package while everything +else moved to `tools.jackson`: it is the shared piece. + +The last line resolves which jar the annotation actually came from at runtime — +a single `jackson-annotations-2.22.jar`, serving both. + +One thing the guides do not mention: Jackson 3's built-in `java.time` support does not +extend to the Jackson 2 mapper sitting beside it. The coexistence module still needs +`jackson-datatype-jsr310` and `jackson-datatype-jdk8` for the Jackson 2 side. Nothing is +shared between them except the annotations. + +## S08 — wire-format equivalence + +[`S08WireFormatEquivalence.java`](../coexistence/src/main/java/com/ankurm/migration/coexist/S08WireFormatEquivalence.java) + +The comparison post's final AI prompt asks for a cross-version regression suite. This is +that suite, written and run. Ten cases go through both mappers and the JSON strings are +compared exactly. + +``` +plain record identical +nested list identical +map identical +java.time identical +Optional present identical +Optional empty identical +NON_NULL identical +@JsonProperty identical +@JsonFormat identical +nulls and empties identical + +cases : 10 +mismatches: 0 +Wire format is identical across both majors for these cases. +``` + +Zero mismatches. The guides' claim that the serialised form is unchanged holds for +records, nested lists, maps, `java.time`, present and empty `Optional`, `NON_NULL` +inclusion, `@JsonProperty` renaming, `@JsonFormat` patterns, and empty collections. + +This matters most if your JSON is cached, signed, hashed, or consumed by a system you +do not control — those are the cases where a field-order or date-format change would be +a production incident rather than a cosmetic difference. Run this suite against your own +DTOs before switching such a service. diff --git a/docs/output/after-S01MapperConstruction.txt b/docs/output/after-S01MapperConstruction.txt new file mode 100644 index 0000000..af2edab --- /dev/null +++ b/docs/output/after-S01MapperConstruction.txt @@ -0,0 +1,4 @@ +output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"} +forked indented : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" } +original intact : false +set*() mutators : 0 <- mutation is impossible, not merely discouraged diff --git a/docs/output/after-S02ExceptionHierarchy.txt b/docs/output/after-S02ExceptionHierarchy.txt new file mode 100644 index 0000000..82729d1 --- /dev/null +++ b/docs/output/after-S02ExceptionHierarchy.txt @@ -0,0 +1,5 @@ +JacksonException extends RuntimeException : true +JacksonException extends IOException : false +ESCAPED catch (IOException) : StreamReadException +caught by catch (Jackson...) : StreamReadException +writeValueAsString declares : [class tools.jackson.core.JacksonException] <- nothing checked diff --git a/docs/output/after-S03CustomHandlers.txt b/docs/output/after-S03CustomHandlers.txt new file mode 100644 index 0000000..ad10e73 --- /dev/null +++ b/docs/output/after-S03CustomHandlers.txt @@ -0,0 +1,3 @@ +serialised : {"amount":19.99,"currency":"USD"} +deserialised : Money[amount=20.0, currencyCode=USD] +base classes : tools.jackson.databind.ValueSerializer / tools.jackson.databind.ValueDeserializer diff --git a/docs/output/after-S04ModulesAndRecords.txt b/docs/output/after-S04ModulesAndRecords.txt new file mode 100644 index 0000000..4bf34c0 --- /dev/null +++ b/docs/output/after-S04ModulesAndRecords.txt @@ -0,0 +1,4 @@ +no modules : {"id":1,"departure":"2026-09-15","seat":"12A"} +round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]] +dates : {"d":"2026-09-15"} +empty Optional : {"id":2,"departure":"2026-09-16","seat":null} diff --git a/docs/output/after-S05DefaultTyping.txt b/docs/output/after-S05DefaultTyping.txt new file mode 100644 index 0000000..ec716c5 --- /dev/null +++ b/docs/output/after-S05DefaultTyping.txt @@ -0,0 +1,7 @@ +enableDefaultTyping on Jackson 3 ObjectMapper : false +activateDefaultTyping on Jackson 3 ObjectMapper : false +activateDefaultTyping on JsonMapper.Builder : true + +written : {"@class":"com.ankurm.migration.after.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.after.S05DefaultTyping$SafePayload","note":"ok"}} +read : SafePayload[ok] +rogue : rejected with InvalidTypeIdException diff --git a/docs/output/after-S07DefaultsThatFlipped.txt b/docs/output/after-S07DefaultsThatFlipped.txt new file mode 100644 index 0000000..7991284 --- /dev/null +++ b/docs/output/after-S07DefaultsThatFlipped.txt @@ -0,0 +1,9 @@ +FAIL_ON_TRAILING_TOKENS : true +FAIL_ON_UNKNOWN_PROPERTIES : false +ALLOW_FINAL_FIELDS_AS_MUTATORS: false +DEFAULT_VIEW_INCLUSION : false +AUTO_DETECT_CREATORS exists : false + +concatenated JSON -> rejected: MismatchedInputException +trailing garbage -> rejected: StreamReadException +unknown property -> accepted: OrderDto[orderId=1] diff --git a/docs/output/before-S01MapperConstruction.txt b/docs/output/before-S01MapperConstruction.txt new file mode 100644 index 0000000..0cb5e99 --- /dev/null +++ b/docs/output/before-S01MapperConstruction.txt @@ -0,0 +1,4 @@ +output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"} +after mutation : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" } +mutation stuck : true +set*() mutators : 40 diff --git a/docs/output/before-S02ExceptionHierarchy.txt b/docs/output/before-S02ExceptionHierarchy.txt new file mode 100644 index 0000000..42c8878 --- /dev/null +++ b/docs/output/before-S02ExceptionHierarchy.txt @@ -0,0 +1,3 @@ +JsonProcessingException extends IOException : true +caught by catch (IOException) : JsonParseException +writeValueAsString declares : [class com.fasterxml.jackson.core.JsonProcessingException] diff --git a/docs/output/before-S03CustomHandlers.txt b/docs/output/before-S03CustomHandlers.txt new file mode 100644 index 0000000..d676e59 --- /dev/null +++ b/docs/output/before-S03CustomHandlers.txt @@ -0,0 +1,3 @@ +serialised : {"amount":19.99,"currency":"USD"} +deserialised : Money[amount=20.0, currencyCode=USD] +base classes : com.fasterxml.jackson.databind.JsonSerializer / com.fasterxml.jackson.databind.JsonDeserializer diff --git a/docs/output/before-S04ModulesAndRecords.txt b/docs/output/before-S04ModulesAndRecords.txt new file mode 100644 index 0000000..640d68b --- /dev/null +++ b/docs/output/before-S04ModulesAndRecords.txt @@ -0,0 +1,4 @@ +with modules : {"id":1,"departure":"2026-09-15","seat":"12A"} +round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]] +bare mapper : FAILS -> InvalidDefinitionException +bare, dates only: FAILS -> InvalidDefinitionException diff --git a/docs/output/before-S05DefaultTyping.txt b/docs/output/before-S05DefaultTyping.txt new file mode 100644 index 0000000..ba54d6d --- /dev/null +++ b/docs/output/before-S05DefaultTyping.txt @@ -0,0 +1,3 @@ +enableDefaultTyping on Jackson 2.22 ObjectMapper : true +written : {"@class":"com.ankurm.migration.before.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.before.S05DefaultTyping$SafePayload","note":"ok"}} +read : SafePayload[ok] diff --git a/docs/output/before-S07DefaultsThatFlipped.txt b/docs/output/before-S07DefaultsThatFlipped.txt new file mode 100644 index 0000000..eebba76 --- /dev/null +++ b/docs/output/before-S07DefaultsThatFlipped.txt @@ -0,0 +1,9 @@ +FAIL_ON_TRAILING_TOKENS : false +FAIL_ON_UNKNOWN_PROPERTIES : true +ALLOW_FINAL_FIELDS_AS_MUTATORS: true +DEFAULT_VIEW_INCLUSION : true +AUTO_DETECT_CREATORS exists : true + +concatenated JSON -> accepted: OrderDto[orderId=1] +trailing garbage -> accepted: OrderDto[orderId=1] +unknown property -> rejected: UnrecognizedPropertyException diff --git a/docs/output/coexistence-S06BothOnOneClasspath.txt b/docs/output/coexistence-S06BothOnOneClasspath.txt new file mode 100644 index 0000000..64bf077 --- /dev/null +++ b/docs/output/coexistence-S06BothOnOneClasspath.txt @@ -0,0 +1,12 @@ +jackson 2 class : com.fasterxml.jackson.databind.ObjectMapper +jackson 3 class : tools.jackson.databind.json.JsonMapper + +jackson 2 output: {"booking_id":1,"travel_date":"2026-09-15"} +jackson 3 output: {"booking_id":1,"travel_date":"2026-09-15"} +wire-compatible : true + +3 reads 2's JSON: Booking[id=1, travelDate=2026-09-15] +2 reads 3's JSON: Booking[id=1, travelDate=2026-09-15] + +annotation from : jackson-annotations-2.22.jar + <- one jackson-annotations jar, used by both majors diff --git a/docs/output/coexistence-S08WireFormatEquivalence.txt b/docs/output/coexistence-S08WireFormatEquivalence.txt new file mode 100644 index 0000000..c8ae501 --- /dev/null +++ b/docs/output/coexistence-S08WireFormatEquivalence.txt @@ -0,0 +1,14 @@ +plain record identical +nested list identical +map identical +java.time identical +Optional present identical +Optional empty identical +NON_NULL identical +@JsonProperty identical +@JsonFormat identical +nulls and empties identical + +cases : 10 +mismatches: 0 +Wire format is identical across both majors for these cases. diff --git a/docs/s01-mapper-construction.md b/docs/s01-mapper-construction.md new file mode 100644 index 0000000..89c1830 --- /dev/null +++ b/docs/s01-mapper-construction.md @@ -0,0 +1,58 @@ +# S01 — Mapper construction and immutability + +Guide: (Steps 2 and 3) + +[`before/S01MapperConstruction.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S01MapperConstruction.java) · +[`after/S01MapperConstruction.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java) + +## The guide's "after" snippet does not compile + +```java +JsonMapper mapper = JsonMapper.builder() + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) // no such constant + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .serializationInclusion(JsonInclude.Include.NON_NULL) // no such method + .build(); +``` + +Two of those four lines are invalid against Jackson 3.2.1: + +- `SerializationFeature.WRITE_DATES_AS_TIMESTAMPS` does not exist. The flag moved to + `tools.jackson.databind.cfg.DateTimeFeature` and now defaults to off, so ISO-8601 + output needs no configuration and there is nothing to disable. +- `JsonMapper.Builder.serializationInclusion(...)` does not exist. The real API is + `changeDefaultPropertyInclusion(UnaryOperator)`. + +The third line is valid but redundant — `FAIL_ON_UNKNOWN_PROPERTIES` already defaults +to `false` in Jackson 3. + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"} +after mutation : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" } +mutation stuck : true +set*() mutators : 40 +``` + +**Jackson 3 — `jackson3-after`** + +``` +output : {"id":1,"travelDate":"2026-09-15","seatPreference":"aisle"} +forked indented : { "id" : 1, "travelDate" : "2026-09-15", "seatPreference" : "aisle" } +original intact : false +set*() mutators : 0 <- mutation is impossible, not merely discouraged +``` + +Both produce the same JSON, which is the reassuring part. The interesting lines are the +last two of each. + +In Jackson 2 the mapper stays mutable forever. `mapper.enable(INDENT_OUTPUT)` after the +mapper has been injected into a dozen components takes effect for all of them — +`mutation stuck : true`. Forty `set*` methods are available to do it with. + +In Jackson 3 there are zero. To vary configuration you fork: `rebuild()` returns a +builder seeded from the existing mapper, and the original is untouched +(`original intact : false`). diff --git a/docs/s02-exception-hierarchy.md b/docs/s02-exception-hierarchy.md new file mode 100644 index 0000000..3e27a0e --- /dev/null +++ b/docs/s02-exception-hierarchy.md @@ -0,0 +1,47 @@ +# S02 — The exception hierarchy + +Guide: ("JacksonException Is Now Unchecked") + +[`before/S02ExceptionHierarchy.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S02ExceptionHierarchy.java) · +[`after/S02ExceptionHierarchy.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java) + +The guide calls this the most dangerous change and it is right. But the failure mode is +narrower than "every `catch (IOException)` silently stops catching", and the difference +decides how much of your codebase you actually have to audit. + +**Case 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 finds these for you. No audit needed. + +**Case B — the try block also does real I/O.** `IOException` is still reachable from the +I/O, so the catch block compiles, and it silently stops covering the Jackson call. +Nothing warns you. This is the shape that reaches production, and it is what the +example runs. + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +JsonProcessingException extends IOException : true +caught by catch (IOException) : JsonParseException +writeValueAsString declares : [class com.fasterxml.jackson.core.JsonProcessingException] +``` + +**Jackson 3 — `jackson3-after`** + +``` +JacksonException extends RuntimeException : true +JacksonException extends IOException : false +ESCAPED catch (IOException) : StreamReadException +caught by catch (Jackson...) : StreamReadException +writeValueAsString declares : [class tools.jackson.core.JacksonException] <- nothing checked +``` + +So the audit target is narrower and more specific than "grep for `catch (IOException)`": +it is `catch (IOException)` blocks that contain **both** I/O and a Jackson call. Those +are the ones the compiler cannot help with. + +Note the last line of each. Jackson 3 still *declares* `JacksonException` on +`writeValueAsString`, but since it is unchecked, callers are not forced to handle it — +which is what lets Jackson calls sit inside lambdas without a wrapper. diff --git a/docs/s03-custom-handlers.md b/docs/s03-custom-handlers.md new file mode 100644 index 0000000..58dc7bd --- /dev/null +++ b/docs/s03-custom-handlers.md @@ -0,0 +1,43 @@ +# S03 — Custom serialiser and deserialiser renames + +Guide: ("Custom Serializers and Deserializers: Class Renames") + +[`before/S03CustomHandlers.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S03CustomHandlers.java) · +[`after/S03CustomHandlers.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S03CustomHandlers.java) + +If your project has custom handlers, this is where the upgrade time goes. Every item +below is a compile error until fixed. + +| Jackson 2 | Jackson 3 | +|---|---| +| `JsonSerializer` | `ValueSerializer` | +| `JsonDeserializer` | `ValueDeserializer` | +| `SerializerProvider` | `SerializationContext` | +| `throws IOException` | remove — unchecked now | +| `gen.writeNumberField(...)` | `gen.writeNumberProperty(...)` | +| `gen.writeStringField(...)` | `gen.writeStringProperty(...)` | +| `p.getCodec().readTree(p)` | `ctxt.readTree(p)` | +| `new SimpleModule(name, Version)` | `new SimpleModule(name)` | +| `mapper.registerModule(m)` | `builder.addModule(m)` | + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +serialised : {"amount":19.99,"currency":"USD"} +deserialised : Money[amount=20.0, currencyCode=USD] +base classes : com.fasterxml.jackson.databind.JsonSerializer / com.fasterxml.jackson.databind.JsonDeserializer +``` + +**Jackson 3 — `jackson3-after`** + +``` +serialised : {"amount":19.99,"currency":"USD"} +deserialised : Money[amount=20.0, currencyCode=USD] +base classes : tools.jackson.databind.ValueSerializer / tools.jackson.databind.ValueDeserializer +``` + +Identical JSON on both sides; only the base class names differ. That is the useful +result — this is a mechanical rename, not a behavioural change, so it is safe to do in +bulk with OpenRewrite and review as a diff. diff --git a/docs/s04-modules-and-records.md b/docs/s04-modules-and-records.md new file mode 100644 index 0000000..791eaea --- /dev/null +++ b/docs/s04-modules-and-records.md @@ -0,0 +1,56 @@ +# S04 — Modules, records, Optional and dates + +Guide: (Step 5) and the "Dependency Footprint" table + +[`before/S04ModulesAndRecords.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S04ModulesAndRecords.java) · +[`after/S04ModulesAndRecords.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S04ModulesAndRecords.java) + +The clearest win in the whole migration. Compare the two poms: + +**[`jackson2-before/pom.xml`](../jackson2-before/pom.xml)** — four artifacts plus a compiler flag: + +```xml +com.fasterxml.jackson.core:jackson-databind +com.fasterxml.jackson.datatype:jackson-datatype-jsr310 +com.fasterxml.jackson.datatype:jackson-datatype-jdk8 +com.fasterxml.jackson.module:jackson-module-parameter-names +... +-parameters +``` + +**[`jackson3-after/pom.xml`](../jackson3-after/pom.xml)** — one artifact, no compiler args: + +```xml +tools.jackson.core:jackson-databind +``` + +The three module classes have no `tools.jackson` equivalent, so leaving the +`registerModule` calls in place is a compile error, not a no-op. Delete them. + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +with modules : {"id":1,"departure":"2026-09-15","seat":"12A"} +round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]] +bare mapper : FAILS -> InvalidDefinitionException +bare, dates only: FAILS -> InvalidDefinitionException +``` + +**Jackson 3 — `jackson3-after`** + +``` +no modules : {"id":1,"departure":"2026-09-15","seat":"12A"} +round-trip : TravelPlan[id=1, departure=2026-09-15, seat=Optional[12A]] +dates : {"d":"2026-09-15"} +empty Optional : {"id":2,"departure":"2026-09-16","seat":null} +``` + +The first two lines are byte-identical across versions. The difference is everything +that had to be set up to get there. + +The `bare mapper` lines in the Jackson 2 output are the point: without the modules, a +Jackson 2 mapper cannot serialise `Optional` or `LocalDate` at all — it throws +`InvalidDefinitionException`. The Jackson 3 mapper in the "after" file has no modules +registered because there are none to register. diff --git a/docs/s05-default-typing.md b/docs/s05-default-typing.md new file mode 100644 index 0000000..0ccbd89 --- /dev/null +++ b/docs/s05-default-typing.md @@ -0,0 +1,56 @@ +# S05 — Default typing + +Guides: (Step 4) and + + +[`before/S05DefaultTyping.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java) · +[`after/S05DefaultTyping.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java) + +## Two corrections + +**`enableDefaultTyping()` was not removed in 2.16.** Both guides say it was. It is still +on `ObjectMapper` in Jackson 2.22.1, deprecated. The "before" program prints the +reflective check. This changes the migration story: the method survives every 2.x +upgrade, so Jackson 3 is where a codebase using it finally fails to compile — which is +also the guides' own argument for why the removal is a useful security forcing function. + +**The remediation snippet is Jackson 2.** The security post shows: + +```java +ObjectMapper mapper = new ObjectMapper(); +mapper.activateDefaultTyping(safeTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL, ...); +``` + +Neither half compiles against Jackson 3. `activateDefaultTyping` is on +`JsonMapper.Builder` only, and `DefaultTyping` is now a top-level enum in +`tools.jackson.databind` rather than nested in `ObjectMapper`. + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +enableDefaultTyping on Jackson 2.22 ObjectMapper : true +written : {"@class":"com.ankurm.migration.before.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.before.S05DefaultTyping$SafePayload","note":"ok"}} +read : SafePayload[ok] +``` + +**Jackson 3 — `jackson3-after`** + +``` +enableDefaultTyping on Jackson 3 ObjectMapper : false +activateDefaultTyping on Jackson 3 ObjectMapper : false +activateDefaultTyping on JsonMapper.Builder : true + +written : {"@class":"com.ankurm.migration.after.S05DefaultTyping$Envelope","body":{"@class":"com.ankurm.migration.after.S05DefaultTyping$SafePayload","note":"ok"}} +read : SafePayload[ok] +rogue : rejected with InvalidTypeIdException +``` + +A third trap, in neither guide: with `DefaultTyping.NON_FINAL` Jackson writes a type id +for the **root** object too. So the root class has to be in the allowlist as well as the +payload hierarchy. Allowlist only the payload base type and the happy path fails, not +just the attack path — which looks like a broken validator when it is working correctly. + +The final `rogue` line is the negative test: a real class, present on the classpath, +deserialisable in every other respect, refused at type resolution before instantiation. diff --git a/docs/s07-defaults-that-flipped.md b/docs/s07-defaults-that-flipped.md new file mode 100644 index 0000000..429d167 --- /dev/null +++ b/docs/s07-defaults-that-flipped.md @@ -0,0 +1,61 @@ +# S07 — Defaults that flipped silently + +Guide: ("Removed and Changed Default Features") + +[`before/S07DefaultsThatFlipped.java`](../jackson2-before/src/main/java/com/ankurm/migration/before/S07DefaultsThatFlipped.java) · +[`after/S07DefaultsThatFlipped.java`](../jackson3-after/src/main/java/com/ankurm/migration/after/S07DefaultsThatFlipped.java) + +These are the expensive ones. No compile error, no warning — behaviour just changes. +The two programs run identical probes so the diff is the migration surface: + +```bash +diff docs/output/before-S07DefaultsThatFlipped.txt \ + docs/output/after-S07DefaultsThatFlipped.txt +``` + +## Output + +**Jackson 2 — `jackson2-before`** + +``` +FAIL_ON_TRAILING_TOKENS : false +FAIL_ON_UNKNOWN_PROPERTIES : true +ALLOW_FINAL_FIELDS_AS_MUTATORS: true +DEFAULT_VIEW_INCLUSION : true +AUTO_DETECT_CREATORS exists : true + +concatenated JSON -> accepted: OrderDto[orderId=1] +trailing garbage -> accepted: OrderDto[orderId=1] +unknown property -> rejected: UnrecognizedPropertyException +``` + +**Jackson 3 — `jackson3-after`** + +``` +FAIL_ON_TRAILING_TOKENS : true +FAIL_ON_UNKNOWN_PROPERTIES : false +ALLOW_FINAL_FIELDS_AS_MUTATORS: false +DEFAULT_VIEW_INCLUSION : false +AUTO_DETECT_CREATORS exists : false + +concatenated JSON -> rejected: MismatchedInputException +trailing garbage -> rejected: StreamReadException +unknown property -> accepted: OrderDto[orderId=1] +``` + +| Setting | Jackson 2 | Jackson 3 | Effect | +|---|---|---|---| +| `FAIL_ON_TRAILING_TOKENS` | off | **on** | Concatenated or double-encoded JSON that used to parse now throws | +| `FAIL_ON_UNKNOWN_PROPERTIES` | **on** | off | Unknown fields are tolerated by default; strictness must be opted into | +| `ALLOW_FINAL_FIELDS_AS_MUTATORS` | on | **off** | Jackson no longer overwrites `final` fields by reflection | +| `DEFAULT_VIEW_INCLUSION` | on | **off** | Review any `@JsonView` usage | +| `AUTO_DETECT_CREATORS` | present | **gone** | Constant removed — but see below | + +The `FAIL_ON_UNKNOWN_PROPERTIES` flip is worth dwelling on because it moves in the +*lenient* direction. Code that relied on a strict mapper to reject malformed payloads +silently stops rejecting them after the upgrade. If you were using deserialisation +failure as input validation, re-enable it explicitly. + +On `AUTO_DETECT_CREATORS`: the constant is gone, but the behaviour it governed is not. +Single-argument constructors are still detected as delegating creators — demonstrated in +the [features repo](https://ankurm.com/git.app/asmhatre/jackson3-by-example). diff --git a/jackson2-before/pom.xml b/jackson2-before/pom.xml new file mode 100644 index 0000000..fcd9816 --- /dev/null +++ b/jackson2-before/pom.xml @@ -0,0 +1,60 @@ + + + 4.0.0 + + com.ankurm + jackson2-to-3-migration + 1.0.0 + + + jackson2-before + jackson2-before + The "before" side: Jackson 2 with the three modules it needs + + + + + com.fasterxml.jackson.core + jackson-databind + ${jackson2.version} + + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + ${jackson2.version} + + + + com.fasterxml.jackson.datatype + jackson-datatype-jdk8 + ${jackson2.version} + + + + com.fasterxml.jackson.module + jackson-module-parameter-names + ${jackson2.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + -parameters + + + + org.codehaus.mojo + exec-maven-plugin + + + + diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S01MapperConstruction.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S01MapperConstruction.java new file mode 100644 index 0000000..89e09d4 --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S01MapperConstruction.java @@ -0,0 +1,45 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-migration-guide/ (Steps 2 and 3) + * + * The canonical Jackson 2 mapper: mutable, built with chained setters, and needing + * two explicit module registrations. Compare with after/S01MapperConstruction. + */ +public class S01MapperConstruction { + + public record Booking(Long id, LocalDate travelDate, Optional seatPreference, String note) { } + + public static void main(String[] args) throws Exception { + ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) // required for java.time + .registerModule(new Jdk8Module()) // required for Optional + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .setSerializationInclusion(JsonInclude.Include.NON_NULL); + + Booking booking = new Booking(1L, LocalDate.of(2026, 9, 15), Optional.of("aisle"), null); + System.out.println("output : " + mapper.writeValueAsString(booking)); + + // The Jackson 2 hazard: the mapper stays mutable after it has been shared. + // This call takes effect, changing behaviour for every other holder. + mapper.enable(SerializationFeature.INDENT_OUTPUT); + System.out.println("after mutation : " + + mapper.writeValueAsString(booking).replace("\n", " ").replaceAll("\\s+", " ")); + System.out.println("mutation stuck : " + + mapper.getSerializationConfig().isEnabled(SerializationFeature.INDENT_OUTPUT)); + + System.out.println("set*() mutators : " + java.util.Arrays.stream(ObjectMapper.class.getMethods()) + .filter(m -> m.getName().startsWith("set")).count()); + } +} diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S02ExceptionHierarchy.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S02ExceptionHierarchy.java new file mode 100644 index 0000000..50d6fb1 --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S02ExceptionHierarchy.java @@ -0,0 +1,47 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; + +/** + * BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "JacksonException Is Now Unchecked: A Silent Production Bug" + * + * In Jackson 2 the whole hierarchy sits under IOException, so `catch (IOException)` + * catches mapping failures — and the compiler forces you to catch something. + */ +public class S02ExceptionHierarchy { + + public record OrderDto(Long orderId, String customerName) { } + + public static void main(String[] args) { + System.out.println("JsonProcessingException extends IOException : " + + IOException.class.isAssignableFrom(JsonProcessingException.class)); + + ObjectMapper mapper = new ObjectMapper(); + String malformed = "{\"orderId\": not-json}"; + + // This is the code that exists all over Jackson 2 codebases. It works. + try { + mapper.readValue(malformed, OrderDto.class); + System.out.println("unreachable"); + } catch (IOException e) { + System.out.println("caught by catch (IOException) : " + e.getClass().getSimpleName()); + } + + // And the checked exception has to be declared or handled everywhere, which + // is why Jackson 2 code is full of wrapper helpers around lambdas. + System.out.println("writeValueAsString declares : " + + java.util.Arrays.toString(getDeclared(mapper))); + } + + private static Class[] getDeclared(ObjectMapper mapper) { + try { + return ObjectMapper.class.getMethod("writeValueAsString", Object.class).getExceptionTypes(); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S03CustomHandlers.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S03CustomHandlers.java new file mode 100644 index 0000000..c8e8f3f --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S03CustomHandlers.java @@ -0,0 +1,67 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.Version; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; + +import java.io.IOException; +import java.math.BigDecimal; + +/** + * BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "Custom Serializers and Deserializers: Class Renames" + * + * Note every element that changes in Jackson 3: + * JsonSerializer -> ValueSerializer + * JsonDeserializer -> ValueDeserializer + * SerializerProvider -> SerializationContext + * throws IOException -> removed + * writeNumberField -> writeNumberProperty + * writeStringField -> writeStringProperty + * new SimpleModule(name, Version) -> new SimpleModule(name) + * mapper.registerModule(m) -> builder.addModule(m) + */ +public class S03CustomHandlers { + + public record Money(BigDecimal amount, String currencyCode) { } + + static class MoneySerializer extends JsonSerializer { + @Override + public void serialize(Money value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeStartObject(); + gen.writeNumberField("amount", value.amount()); + gen.writeStringField("currency", value.currencyCode().toUpperCase()); + gen.writeEndObject(); + } + } + + static class MoneyDeserializer extends JsonDeserializer { + @Override + public Money deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + var node = p.getCodec().readTree(p); + return new Money( + new BigDecimal(((com.fasterxml.jackson.databind.JsonNode) node).path("amount").asText("0")), + ((com.fasterxml.jackson.databind.JsonNode) node).path("currency").asText("GBP")); + } + } + + public static void main(String[] args) throws Exception { + SimpleModule module = new SimpleModule("MoneyModule", new Version(1, 0, 0, null, null, null)); + module.addSerializer(Money.class, new MoneySerializer()); + module.addDeserializer(Money.class, new MoneyDeserializer()); + + ObjectMapper mapper = new ObjectMapper().registerModule(module); + + System.out.println("serialised : " + mapper.writeValueAsString(new Money(new BigDecimal("19.99"), "usd"))); + System.out.println("deserialised : " + mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class)); + System.out.println("base classes : " + MoneySerializer.class.getSuperclass().getName() + + " / " + MoneyDeserializer.class.getSuperclass().getName()); + } +} diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S04ModulesAndRecords.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S04ModulesAndRecords.java new file mode 100644 index 0000000..ecaa658 --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S04ModulesAndRecords.java @@ -0,0 +1,51 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-migration-guide/ (Step 5) + * and https://ankurm.com/jackson-3-vs-jackson-2/ ("Dependency Footprint") + * + * Three separate artifacts and three registerModule calls, and the build needs + * -parameters (see this module's pom.xml). Everything below is deleted in + * after/S04ModulesAndRecords. + * + * The bare mapper at the bottom shows exactly what breaks without them. + */ +public class S04ModulesAndRecords { + + public record TravelPlan(Long id, LocalDate departure, Optional seat) { } + + public static void main(String[] args) throws Exception { + ObjectMapper fullyEquipped = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .registerModule(new Jdk8Module()) + .registerModule(new ParameterNamesModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + TravelPlan plan = new TravelPlan(1L, LocalDate.of(2026, 9, 15), Optional.of("12A")); + String json = fullyEquipped.writeValueAsString(plan); + System.out.println("with modules : " + json); + System.out.println("round-trip : " + fullyEquipped.readValue(json, TravelPlan.class)); + + // What a bare Jackson 2 mapper does with the same object. + ObjectMapper bare = new ObjectMapper(); + System.out.println("bare mapper : " + describe(bare, plan)); + System.out.println("bare, dates only: " + describe(bare, java.util.Map.of("d", LocalDate.of(2026, 9, 15)))); + } + + private static String describe(ObjectMapper mapper, Object value) { + try { + return mapper.writeValueAsString(value); + } catch (Exception e) { + return "FAILS -> " + e.getClass().getSimpleName(); + } + } +} diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java new file mode 100644 index 0000000..527747f --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S05DefaultTyping.java @@ -0,0 +1,56 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; + +/** + * BEFORE — Jackson 2. Posts: https://ankurm.com/jackson-security-best-practices/ + * and https://ankurm.com/jackson-3-migration-guide/ (Step 4) + * + * Two things to notice: + * 1. CORRECTION TO THE POSTS. Both guides state that enableDefaultTyping() was + * "deprecated in 2.10, removed in 2.16". It is still present on ObjectMapper in + * Jackson 2.22.1 — deprecated, but there. The reflective check below prints the + * truth. This matters for migration planning: a Jackson 2 codebase can still be + * compiling against it today, so the Jackson 3 upgrade is where it finally + * breaks, not the 2.16 upgrade. + * 2. activateDefaultTyping is an INSTANCE method on the mapper here. In Jackson 3 + * it moves to the builder — see after/S05DefaultTyping. + */ +public class S05DefaultTyping { + + public abstract static class BasePayload { } + public static class SafePayload extends BasePayload { + public String note; + public SafePayload() { } + public SafePayload(String n) { note = n; } + @Override public String toString() { return "SafePayload[" + note + "]"; } + } + public static class Envelope { + public Object body; + public Envelope() { } + public Envelope(Object b) { body = b; } + } + + public static void main(String[] args) throws Exception { + boolean enableExists = java.util.Arrays.stream(ObjectMapper.class.getMethods()) + .anyMatch(m -> m.getName().equals("enableDefaultTyping")); + System.out.println("enableDefaultTyping on Jackson 2.22 ObjectMapper : " + enableExists); + + PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType(Envelope.class) + .allowIfSubType(BasePayload.class) + .build(); + + ObjectMapper mapper = new ObjectMapper(); + mapper.activateDefaultTyping(validator, // instance method + ObjectMapper.DefaultTyping.NON_FINAL, // nested enum + JsonTypeInfo.As.PROPERTY); + + String json = mapper.writeValueAsString(new Envelope(new SafePayload("ok"))); + System.out.println("written : " + json); + System.out.println("read : " + mapper.readValue(json, Envelope.class).body); + } +} diff --git a/jackson2-before/src/main/java/com/ankurm/migration/before/S07DefaultsThatFlipped.java b/jackson2-before/src/main/java/com/ankurm/migration/before/S07DefaultsThatFlipped.java new file mode 100644 index 0000000..d07df05 --- /dev/null +++ b/jackson2-before/src/main/java/com/ankurm/migration/before/S07DefaultsThatFlipped.java @@ -0,0 +1,47 @@ +package com.ankurm.migration.before; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * BEFORE — Jackson 2. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "Removed and Changed Default Features" + * + * These produce no compile error on upgrade. They change behaviour silently, which + * makes them the most expensive category to find. Run this and after/S07 side by + * side and diff the two outputs. + */ +public class S07DefaultsThatFlipped { + + public record OrderDto(Long orderId) { } + + public static void main(String[] args) { + ObjectMapper mapper = new ObjectMapper(); + + System.out.println("FAIL_ON_TRAILING_TOKENS : " + + mapper.getDeserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)); + System.out.println("FAIL_ON_UNKNOWN_PROPERTIES : " + + mapper.getDeserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)); + System.out.println("ALLOW_FINAL_FIELDS_AS_MUTATORS: " + + mapper.getSerializationConfig().isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)); + System.out.println("DEFAULT_VIEW_INCLUSION : " + + mapper.getSerializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)); + System.out.println("AUTO_DETECT_CREATORS exists : " + + java.util.Arrays.stream(MapperFeature.values()) + .anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"))); + System.out.println(); + + System.out.println("concatenated JSON -> " + read(mapper, "{\"orderId\":1} {\"orderId\":2}")); + System.out.println("trailing garbage -> " + read(mapper, "{\"orderId\":1}garbage")); + System.out.println("unknown property -> " + read(mapper, "{\"orderId\":1,\"nope\":2}")); + } + + private static String read(ObjectMapper mapper, String json) { + try { + return "accepted: " + mapper.readValue(json, OrderDto.class); + } catch (Exception e) { + return "rejected: " + e.getClass().getSimpleName(); + } + } +} diff --git a/jackson3-after/pom.xml b/jackson3-after/pom.xml new file mode 100644 index 0000000..31a1ce8 --- /dev/null +++ b/jackson3-after/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.ankurm + jackson2-to-3-migration + 1.0.0 + + + jackson3-after + jackson3-after + The "after" side: Jackson 3, one dependency, no compiler flags + + + + + tools.jackson + jackson-bom + ${jackson3.version} + pom + import + + + + + + + + tools.jackson.core + jackson-databind + + + + + + + org.codehaus.mojo + exec-maven-plugin + + + + diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java new file mode 100644 index 0000000..f11d2c9 --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S01MapperConstruction.java @@ -0,0 +1,56 @@ +package com.ankurm.migration.after; + +import com.fasterxml.jackson.annotation.JsonInclude; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.SerializationFeature; +import tools.jackson.databind.json.JsonMapper; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-migration-guide/ (Steps 2 and 3) + * + * Four corrections to the "after" snippet printed in the post: + * + * 1. .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) DOES NOT COMPILE. + * That constant is not on SerializationFeature in Jackson 3 — it moved to + * tools.jackson.databind.cfg.DateTimeFeature and defaults to OFF, so the line + * is both invalid and unnecessary. Deleted here. + * 2. .serializationInclusion(...) DOES NOT EXIST on JsonMapper.Builder. The real + * API is changeDefaultPropertyInclusion(UnaryOperator). + * 3. FAIL_ON_UNKNOWN_PROPERTIES already defaults to false; the configure() call is + * kept only to show the equivalence, and could be deleted. + * 4. The mapper cannot be mutated afterwards, which is the actual point. + */ +public class S01MapperConstruction { + + public record Booking(Long id, LocalDate travelDate, Optional seatPreference, String note) { } + + public static void main(String[] args) { + JsonMapper mapper = JsonMapper.builder() + // JavaTimeModule and Jdk8Module registrations deleted — both are + // built into jackson-databind 3.x and the classes no longer exist. + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .changeDefaultPropertyInclusion( + incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL)) + .build(); + + Booking booking = new Booking(1L, LocalDate.of(2026, 9, 15), Optional.of("aisle"), null); + System.out.println("output : " + mapper.writeValueAsString(booking)); + + // Varying configuration without touching the shared instance: fork it. + JsonMapper indented = mapper.rebuild() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + System.out.println("forked indented : " + + indented.writeValueAsString(booking).replace("\n", " ").replaceAll("\\s+", " ")); + System.out.println("original intact : " + + mapper.serializationConfig().isEnabled(SerializationFeature.INDENT_OUTPUT)); + + System.out.println("set*() mutators : " + java.util.Arrays.stream( + tools.jackson.databind.ObjectMapper.class.getMethods()) + .filter(m -> m.getName().startsWith("set")).count() + + " <- mutation is impossible, not merely discouraged"); + } +} diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java new file mode 100644 index 0000000..f54e75f --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S02ExceptionHierarchy.java @@ -0,0 +1,71 @@ +package com.ankurm.migration.after; + +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; + +import java.io.IOException; +import java.io.StringReader; + +/** + * AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "JacksonException Is Now Unchecked: A Silent Production Bug" + * + * The post is right that this is the dangerous one, but the failure mode is more + * specific than "your catch blocks stop working". There are two cases: + * + * A. try block contains ONLY Jackson calls -> `catch (IOException)` is now a + * COMPILE ERROR ("never thrown in body"). The compiler catches it for you. + * B. try block also does real I/O -> IOException is still reachable, the catch + * block compiles, and it silently stops covering the Jackson call. + * + * Case B is the one that reaches production, and it is what runs below. + */ +public class S02ExceptionHierarchy { + + public record OrderDto(Long orderId, String customerName) { } + + public static void main(String[] args) { + System.out.println("JacksonException extends RuntimeException : " + + RuntimeException.class.isAssignableFrom(JacksonException.class)); + System.out.println("JacksonException extends IOException : " + + IOException.class.isAssignableFrom(JacksonException.class)); + + JsonMapper mapper = JsonMapper.builder().build(); + String malformed = "{\"orderId\": not-json}"; + + // CASE B — compiles, and misses the Jackson failure entirely. + try { + try (StringReader reader = new StringReader(malformed)) { + reader.read(); // keeps IOException reachable + mapper.readValue(malformed, OrderDto.class); + System.out.println("unreachable"); + } catch (IOException e) { + System.out.println("caught by catch (IOException) : unreachable"); + } + } catch (JacksonException escaped) { + System.out.println("ESCAPED catch (IOException) : " + escaped.getClass().getSimpleName()); + } + + // The fix: catch JacksonException explicitly, before IOException. + try (StringReader reader = new StringReader(malformed)) { + reader.read(); + mapper.readValue(malformed, OrderDto.class); + } catch (JacksonException e) { + System.out.println("caught by catch (Jackson...) : " + e.getClass().getSimpleName()); + } catch (IOException e) { + System.out.println("I/O : " + e.getClass().getSimpleName()); + } + + System.out.println("writeValueAsString declares : " + + java.util.Arrays.toString(declaredExceptions()) + " <- nothing checked"); + } + + private static Class[] declaredExceptions() { + try { + return ObjectMapper.class.getMethod("writeValueAsString", Object.class).getExceptionTypes(); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S03CustomHandlers.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S03CustomHandlers.java new file mode 100644 index 0000000..a47c555 --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S03CustomHandlers.java @@ -0,0 +1,59 @@ +package com.ankurm.migration.after; + +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.module.SimpleModule; + +import java.math.BigDecimal; + +/** + * AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "Custom Serializers and Deserializers: Class Renames" + * + * The same handlers with every rename applied. The `throws IOException` clauses are + * gone, p.getCodec().readTree(p) is replaced by ctxt.readTree(p), and the module is + * attached to the BUILDER because the mapper is immutable once built. + */ +public class S03CustomHandlers { + + public record Money(BigDecimal amount, String currencyCode) { } + + static class MoneySerializer extends ValueSerializer { + @Override + public void serialize(Money value, JsonGenerator gen, SerializationContext ctxt) { + gen.writeStartObject(); + gen.writeNumberProperty("amount", value.amount()); + gen.writeStringProperty("currency", value.currencyCode().toUpperCase()); + gen.writeEndObject(); + } + } + + static class MoneyDeserializer extends ValueDeserializer { + @Override + public Money deserialize(JsonParser p, DeserializationContext ctxt) { + JsonNode node = ctxt.readTree(p); + return new Money( + node.path("amount").decimalValue(BigDecimal.ZERO), + node.path("currency").asString("GBP")); + } + } + + public static void main(String[] args) { + SimpleModule module = new SimpleModule("MoneyModule"); // no Version argument + module.addSerializer(Money.class, new MoneySerializer()); + module.addDeserializer(Money.class, new MoneyDeserializer()); + + JsonMapper mapper = JsonMapper.builder().addModule(module).build(); + + System.out.println("serialised : " + mapper.writeValueAsString(new Money(new BigDecimal("19.99"), "usd"))); + System.out.println("deserialised : " + mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class)); + System.out.println("base classes : " + MoneySerializer.class.getSuperclass().getName() + + " / " + MoneyDeserializer.class.getSuperclass().getName()); + } +} diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S04ModulesAndRecords.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S04ModulesAndRecords.java new file mode 100644 index 0000000..186485d --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S04ModulesAndRecords.java @@ -0,0 +1,41 @@ +package com.ankurm.migration.after; + +import tools.jackson.databind.json.JsonMapper; + +import java.time.LocalDate; +import java.util.Optional; + +/** + * AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-migration-guide/ (Step 5) + * + * The entire "before" setup collapses to JsonMapper.builder().build(). + * + * Deleted, and not replaceable — these Jackson 2 classes have no tools.jackson + * equivalent, so leaving the registerModule calls in place is a compile error: + * - JavaTimeModule (java.time is built in) + * - Jdk8Module (Optional is built in) + * - ParameterNamesModule (records use RecordComponent reflection) + * - the -parameters compiler flag (see this module's pom.xml — there isn't one) + * - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) (constant does not + * exist on SerializationFeature; ISO-8601 is already the default) + */ +public class S04ModulesAndRecords { + + public record TravelPlan(Long id, LocalDate departure, Optional seat) { } + + public static void main(String[] args) { + JsonMapper mapper = JsonMapper.builder().build(); // that is the whole setup + + TravelPlan plan = new TravelPlan(1L, LocalDate.of(2026, 9, 15), Optional.of("12A")); + String json = mapper.writeValueAsString(plan); + System.out.println("no modules : " + json); + System.out.println("round-trip : " + mapper.readValue(json, TravelPlan.class)); + System.out.println("dates : " + + mapper.writeValueAsString(java.util.Map.of("d", LocalDate.of(2026, 9, 15)))); + System.out.println("empty Optional : " + + mapper.writeValueAsString(new TravelPlan(2L, LocalDate.of(2026, 9, 16), Optional.empty()))); + + // Byte-identical to the Jackson 2 output from before/S04ModulesAndRecords — + // the wire format did not change, only the API did. + } +} diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java new file mode 100644 index 0000000..d081e28 --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S05DefaultTyping.java @@ -0,0 +1,73 @@ +package com.ankurm.migration.after; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; + +/** + * AFTER — Jackson 3. Posts: https://ankurm.com/jackson-security-best-practices/ + * and https://ankurm.com/jackson-3-migration-guide/ (Step 4) + * + * Three migration points the posts do not state: + * 1. activateDefaultTyping is NOT on the Jackson 3 mapper either. It lives only + * on JsonMapper.Builder. The security post's `mapper.activateDefaultTyping(...)` + * snippet does not compile against Jackson 3. + * 2. ObjectMapper.DefaultTyping is now a top-level enum, tools.jackson.databind.DefaultTyping. + * 3. With NON_FINAL, the ROOT type also gets a type id, so the root class must be + * allowlisted too — otherwise the happy path fails, not just the attack path. + */ +public class S05DefaultTyping { + + public abstract static class BasePayload { } + public static class SafePayload extends BasePayload { + public String note; + public SafePayload() { } + public SafePayload(String n) { note = n; } + @Override public String toString() { return "SafePayload[" + note + "]"; } + } + public static class RoguePayload { public String note; } + public static class Envelope { + public Object body; + public Envelope() { } + public Envelope(Object b) { body = b; } + } + + public static void main(String[] args) { + for (String name : new String[] { "enableDefaultTyping", "activateDefaultTyping" }) { + System.out.printf("%-22s on Jackson 3 ObjectMapper : %s%n", name, + java.util.Arrays.stream(ObjectMapper.class.getMethods()) + .anyMatch(m -> m.getName().equals(name))); + } + System.out.println("activateDefaultTyping on JsonMapper.Builder : " + + java.util.Arrays.stream(JsonMapper.Builder.class.getMethods()) + .anyMatch(m -> m.getName().equals("activateDefaultTyping"))); + System.out.println(); + + PolymorphicTypeValidator validator = BasicPolymorphicTypeValidator.builder() + .allowIfSubType(Envelope.class) // root needs allowlisting too + .allowIfSubType(BasePayload.class) + .build(); + + JsonMapper mapper = JsonMapper.builder() + .activateDefaultTyping(validator, // builder method + DefaultTyping.NON_FINAL, // top-level enum + JsonTypeInfo.As.PROPERTY) + .build(); + + String json = mapper.writeValueAsString(new Envelope(new SafePayload("ok"))); + System.out.println("written : " + json); + System.out.println("read : " + ((Envelope) mapper.readValue(json, Envelope.class)).body); + + String rogue = "{\"@class\":\"" + Envelope.class.getName() + "\",\"body\":[\"" + + RoguePayload.class.getName() + "\",{\"note\":\"pwn\"}]}"; + try { + mapper.readValue(rogue, Envelope.class); + System.out.println("rogue : UNEXPECTEDLY ACCEPTED"); + } catch (Exception e) { + System.out.println("rogue : rejected with " + e.getClass().getSimpleName()); + } + } +} diff --git a/jackson3-after/src/main/java/com/ankurm/migration/after/S07DefaultsThatFlipped.java b/jackson3-after/src/main/java/com/ankurm/migration/after/S07DefaultsThatFlipped.java new file mode 100644 index 0000000..2a060e1 --- /dev/null +++ b/jackson3-after/src/main/java/com/ankurm/migration/after/S07DefaultsThatFlipped.java @@ -0,0 +1,50 @@ +package com.ankurm.migration.after; + +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.json.JsonMapper; + +/** + * AFTER — Jackson 3. Post: https://ankurm.com/jackson-3-vs-jackson-2/ + * Section: "Removed and Changed Default Features" + * + * Same probes as before/S07DefaultsThatFlipped. Diff the two output files: + * + * diff docs/output/before-S07DefaultsThatFlipped.txt \ + * docs/output/after-S07DefaultsThatFlipped.txt + * + * Everything that differs is a silent behavioural change waiting in your upgrade. + */ +public class S07DefaultsThatFlipped { + + public record OrderDto(Long orderId) { } + + public static void main(String[] args) { + JsonMapper mapper = JsonMapper.builder().build(); + + System.out.println("FAIL_ON_TRAILING_TOKENS : " + + mapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_TRAILING_TOKENS)); + System.out.println("FAIL_ON_UNKNOWN_PROPERTIES : " + + mapper.deserializationConfig().isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)); + System.out.println("ALLOW_FINAL_FIELDS_AS_MUTATORS: " + + mapper.serializationConfig().isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS)); + System.out.println("DEFAULT_VIEW_INCLUSION : " + + mapper.serializationConfig().isEnabled(MapperFeature.DEFAULT_VIEW_INCLUSION)); + System.out.println("AUTO_DETECT_CREATORS exists : " + + java.util.Arrays.stream(MapperFeature.values()) + .anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"))); + System.out.println(); + + System.out.println("concatenated JSON -> " + read(mapper, "{\"orderId\":1} {\"orderId\":2}")); + System.out.println("trailing garbage -> " + read(mapper, "{\"orderId\":1}garbage")); + System.out.println("unknown property -> " + read(mapper, "{\"orderId\":1,\"nope\":2}")); + } + + private static String read(JsonMapper mapper, String json) { + try { + return "accepted: " + mapper.readValue(json, OrderDto.class); + } catch (Exception e) { + return "rejected: " + e.getClass().getSimpleName(); + } + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b3d9750 --- /dev/null +++ b/pom.xml @@ -0,0 +1,40 @@ + + + 4.0.0 + + com.ankurm + jackson2-to-3-migration + 1.0.0 + pom + + jackson2-to-3-migration + Side-by-side Jackson 2 and Jackson 3 code for the ankurm.com migration guides + + + jackson2-before + jackson3-after + coexistence + + + + + 21 + UTF-8 + 2.22.1 + 3.2.1 + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + + + diff --git a/run-all.sh b/run-all.sh new file mode 100755 index 0000000..b672ca3 --- /dev/null +++ b/run-all.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Builds all three modules and runs every example, writing real output to +# docs/output/-.txt. The before/after pairs share a class name so +# the outputs can be diffed directly: +# +# diff docs/output/before-S07DefaultsThatFlipped.txt \ +# docs/output/after-S07DefaultsThatFlipped.txt +set -u +cd "$(dirname "$0")" + +mvn -B -q clean compile || { echo "compile failed"; exit 1; } +mkdir -p docs/output +FAILED=0 + +run_module() { + local module="$1" prefix="$2" pkg="$3" + local cp + cp="$module/target/classes:$(cd "$module" && mvn -B -q dependency:build-classpath \ + -Dmdep.outputFile=/dev/stdout -DincludeScope=runtime 2>/dev/null | tail -1)" + for f in $(find "$module/src/main/java" -name '*.java' | sort); do + grep -q 'public static void main' "$f" || continue + local short class + short=$(basename "$f" .java) + class="$pkg.$short" + printf '%-14s %-32s' "$prefix" "$short" + if java -cp "$cp" "$class" > "docs/output/$prefix-$short.txt" 2>&1; then + echo "ok" + else + echo "FAILED"; FAILED=1 + fi + done +} + +run_module jackson2-before before com.ankurm.migration.before +run_module jackson3-after after com.ankurm.migration.after +run_module coexistence coexistence com.ankurm.migration.coexist + +echo +echo "--- before/after diffs ---" +for after in docs/output/after-*.txt; do + short=$(basename "$after" .txt); short=${short#after-} + before="docs/output/before-$short.txt" + [ -f "$before" ] || continue + if diff -q "$before" "$after" >/dev/null; then + echo " $short: identical output" + else + echo " $short: differs (expected — that is the migration surface)" + fi +done + +echo +if [ "$FAILED" -eq 0 ]; then echo "All examples ran successfully."; else echo "Some examples failed."; fi +exit $FAILED