1
0

Jackson 3 series companion code

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

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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
target/
*.iml
.idea/

123
README.md Normal file
View File

@@ -0,0 +1,123 @@
# jackson3-by-example
Runnable companion code for the eight-part **Jackson 3** series on [ankurm.com](https://ankurm.com).
Every example is a standalone `main()` you can run on its own. Every line of output
in [`docs/`](docs) was produced by [`run-all.sh`](run-all.sh) on the versions below —
nothing is transcribed by hand.
```
Jackson 3.2.1 (tools.jackson.core:jackson-databind)
jackson-annotations 2.22 (com.fasterxml.jackson.core — deliberately unchanged)
JDK Temurin 21.0.5
Maven 3.9.9
```
## Run it
```bash
git clone https://ankurm.com/git.app/asmhatre/jackson3-by-example.git
cd jackson3-by-example
./run-all.sh # compiles, runs all 37 examples, refreshes docs/output/
```
Or run any single example:
```bash
mvn -q compile
mvn -q exec:java -Dexec.mainClass=com.ankurm.jackson3.part5polymorphic.F01SerialiseMixedList
```
## Map: post → code
| Post | Package | Examples | Notes & output |
|---|---|---|---|
| [Jackson 101](https://ankurm.com/jackson-java-tutorial/) | [`part0setup`](src/main/java/com/ankurm/jackson3/part0setup) | [A01](src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java) [A02](src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java) [A03](src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java) | [write-up](docs/part0-setup.md) |
| [ObjectMapper Guide](https://ankurm.com/jackson-objectmapper-guide/) | [`part1objectmapper`](src/main/java/com/ankurm/jackson3/part1objectmapper) | [B01](src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java) [B02](src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java) [B03](src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java) [B04](src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java) | [write-up](docs/part1-objectmapper.md) |
| [Records, Optionals, Sealed](https://ankurm.com/jackson-java-records-optionals/) | [`part2modernjava`](src/main/java/com/ankurm/jackson3/part2modernjava) | [C01](src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java) [C02](src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java) [C03](src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java) [C04](src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java) | [write-up](docs/part2-modern-java.md) |
| [Annotations Cheat Sheet](https://ankurm.com/jackson-annotations-guide/) | [`part3annotations`](src/main/java/com/ankurm/jackson3/part3annotations) | [D01](src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java) [D02](src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java) [D03](src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java) [D04](src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java) | [write-up](docs/part3-annotations.md) |
| [Custom Serialisers & Mix-ins](https://ankurm.com/jackson-custom-serializer-mixin/) | [`part4custom`](src/main/java/com/ankurm/jackson3/part4custom) | [E01](src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java) [E02](src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java) [E03](src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java) [E04](src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java) [E05](src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java) | [write-up](docs/part4-custom.md) |
| [Polymorphic Deserialisation](https://ankurm.com/jackson-polymorphic-deserialization/) | [`part5polymorphic`](src/main/java/com/ankurm/jackson3/part5polymorphic) | [F01](src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java) [F02](src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java) [F03](src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java) [F04](src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java) | [write-up](docs/part5-polymorphic.md) |
| [Streaming API & JsonNode](https://ankurm.com/jackson-streaming-api-jsonnode/) | [`part6streaming`](src/main/java/com/ankurm/jackson3/part6streaming) | [G01](src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java) [G02](src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java) [G03](src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java) [G04](src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java) | [write-up](docs/part6-streaming.md) |
| [Security Best Practices](https://ankurm.com/jackson-security-best-practices/) | [`part7security`](src/main/java/com/ankurm/jackson3/part7security) | [H01](src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java) [H02](src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java) [H03](src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java) [H04](src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java) [H05](src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java) | [write-up](docs/part7-security.md) |
| — (beyond the posts) | [`beyond`](src/main/java/com/ankurm/jackson3/beyond) | [Y01](src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java) [Y02](src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java) [Y03](src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java) [Y04](src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java) [Y05](src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java) [Y06](src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java) | [write-up](docs/beyond.md) |
### Documentation
Each page pairs the code with its real captured output and calls out where the post and
the library disagree.
| Page | Covers |
|---|---|
| [docs/part0-setup.md](docs/part0-setup.md) | Setup, the shared-mapper rule, three processing models |
| [docs/part1-objectmapper.md](docs/part1-objectmapper.md) | Reading, writing, `TypeReference`, property ordering |
| [docs/part2-modern-java.md](docs/part2-modern-java.md) | Records, `Optional`, sealed types and auto-discovery |
| [docs/part3-annotations.md](docs/part3-annotations.md) | The annotation set, and which advice is now obsolete |
| [docs/part4-custom.md](docs/part4-custom.md) | `ValueSerializer`, `ValueDeserializer`, modules, mix-ins |
| [docs/part5-polymorphic.md](docs/part5-polymorphic.md) | `@JsonTypeInfo`, and the dropped-discriminator defect in full |
| [docs/part6-streaming.md](docs/part6-streaming.md) | Streaming, the tree model, and the three approaches measured |
| [docs/part7-security.md](docs/part7-security.md) | Safe polymorphism, allowlists, resource limits |
| [docs/beyond.md](docs/beyond.md) | Unchecked exceptions, flipped defaults, pool tuning |
Raw stdout for all 37 programs is in [`docs/output/`](docs/output), regenerated by
[`run-all.sh`](run-all.sh).
## Corrections to the posts
Writing this code against a real Jackson 3.2.1 build surfaced places where the blog
snippets do not compile or do not behave as printed. Each is demonstrated by a program,
so you can verify rather than take my word for it.
| # | Claim in the post | What actually happens | Shown by |
|---|---|---|---|
| 1 | Maven coordinate `com.fasterxml.jackson.core:jackson-databind:3.1.2` | That artifact does not exist. Jackson 3 is at `tools.jackson.core:jackson-databind`; only `jackson-annotations` keeps the old group ID. | [`pom.xml`](pom.xml) |
| 2 | `JsonMapper.builder().disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)` | Does not compile. The constant is not on `SerializationFeature` in Jackson 3 — it moved to `tools.jackson.databind.cfg.DateTimeFeature`, and it already defaults to off, so ISO-8601 needs no configuration. | [Y03](src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java) |
| 3 | `JsonMapper.builder().serializationInclusion(...)` | No such builder method. The real API is `changeDefaultPropertyInclusion(UnaryOperator)`. | [A02](src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java), [H02](src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java) |
| 4 | `writeValueAsString(List<PaymentMethod>)` emits `paymentType` | It does not. A `List` carries no element type, so the polymorphic serialiser never engages and the discriminator is dropped — and the output then fails to deserialise. Use `writerFor(TypeReference)` or a typed array. | [F01](src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java), [F02](src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java) |
| 5 | Remediate with `mapper.activateDefaultTyping(validator, ...)` | Jackson 3's mapper has no mutators at all — not `enableDefaultTyping`, not `activateDefaultTyping`, not one `set*` method. `activateDefaultTyping` exists only on `JsonMapper.Builder`. | [H02](src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java), [H03](src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java) |
| 6 | `import tools.jackson.core.JsonFactory` | Wrong package. It is `tools.jackson.core.json.JsonFactory`. | [G01](src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java) |
| 7 | Optional needs `jackson-datatype-jdk8`; records need `jackson-module-parameter-names` and `-parameters` | All three are built into `jackson-databind` 3.x. The Jackson 2 module classes do not exist under `tools.jackson`, so leaving the registrations in place is a compile error. | [C01](src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java), [C02](src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java) |
| 8 | Set `FAIL_ON_UNKNOWN_PROPERTIES=false`; use `@JsonIgnoreProperties(ignoreUnknown=true)` to opt out per class | Already false by default in Jackson 3. The annotation now matters only when you deliberately turn strictness back on. | [D03](src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java) |
| 9 | Without `@JsonFormat`, `LocalDate` is written as a numeric array | Jackson 2 behaviour. Jackson 3 writes ISO-8601 by default; the annotation is only needed for a non-standard pattern. | [D02](src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java) |
| 10 | Removing `AUTO_DETECT_CREATORS` means single-arg constructors "quietly fail" | The enum constant is gone, but the behaviour is not: a single-argument constructor is still detected as a delegating creator. Annotate anyway, but the upgrade will not break these classes. | [Y05](src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java) |
| 11 | The custom deserialiser uses `p.getCodec().readTree(p)` and `node.get(...)` | `getCodec()` is gone — use `ctxt.readTree(parser)`. And `get()` NPEs on a missing field; `path()` with a defaulting accessor does not. A bare `decimalValue()` on a `MissingNode` throws in Jackson 3 where Jackson 2 returned zero. | [E02](src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java), [E03](src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java) |
Two smaller ones worth knowing, neither strictly an error in the posts:
- A getter-based POJO serialises its properties **alphabetically**; a record serialises in
declaration order. The post's sample output for `ProductSummary` shows declaration order.
See [B04](src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java).
- `{"amount":20.00}` deserialises to `BigDecimal` **20.0**, not 20.00 — the scale is lost
unless `USE_BIG_DECIMAL_FOR_FLOATS` is enabled. See
[E03](src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java).
## Measured, not asserted
Three examples produce numbers rather than prose. Figures below are from one run on a
2-core container; re-run them on your own hardware.
**Three processing models over a 20 MB / 200k-entry file** ([G04](src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java)):
```
data binding (readValue) errors=4000 524 ms heap delta 47 MB
tree model (readTree) errors=4000 341 ms heap delta 102 MB
streaming (JsonParser) errors=4000 78 ms heap delta 1 MB
```
**Streaming a million records** ([G02](src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java)): 30 MB written in 129 ms with a 0 MB heap delta.
**RecyclerPool** ([Y06](src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java)) — the comparison post suggests restoring the 2.x
thread-local pool if you see a regression. On this box the two pools are within noise of
each other in both shapes, and the gap moves between runs, so the honest conclusion is
that there is no default winner: measure on your own hardware before changing it. What
is stable across runs is that turning recycling off entirely is clearly worse — a useful
negative control confirming the pool is doing something.
```
1 thread threadLocalPool 301 ms concurrentDeque 310 ms nonRecycling 496 ms
8 threads threadLocalPool 451 ms concurrentDeque 450 ms nonRecycling 808 ms
```
## Related
- [jackson2-to-3-migration](https://ankurm.com/git.app/asmhatre/jackson2-to-3-migration) — the before/after companion for the two migration guides.

18
docs/README.md Normal file
View File

@@ -0,0 +1,18 @@
# Documentation
One page per post in the series. Each page lists the examples for that post, states
where the code deviates from the published snippet and why, and embeds the real
captured output.
- [Part 0 — Jackson 101: setup and the ObjectMapper lifecycle](part0-setup.md)
- [Part 1 — ObjectMapper: reading and writing](part1-objectmapper.md)
- [Part 2 — Records, Optionals and sealed types](part2-modern-java.md)
- [Part 3 — Annotations](part3-annotations.md)
- [Part 4 — Custom serialisers, deserialisers and mix-ins](part4-custom.md)
- [Part 5 — Polymorphic deserialisation](part5-polymorphic.md)
- [Part 6 — Streaming API and the tree model](part6-streaming.md)
- [Part 7 — Security](part7-security.md)
- [Beyond the posts](beyond.md)
Raw captured stdout for every program is in [`output/`](output). Those files are
regenerated by [`../run-all.sh`](../run-all.sh); do not edit them by hand.

149
docs/beyond.md Normal file
View File

@@ -0,0 +1,149 @@
# Beyond the posts
Behaviour that no post in the series covers, but that shows up on the first real
upgrade. Every claim here is printed by a program rather than asserted.
## Y01 — unchecked exceptions
[`Y01UncheckedExceptions.java`](../src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java)
`JacksonException extends RuntimeException`. The comparison post is right that this is
the most dangerous change, but the failure mode is more specific than "your catch blocks
stop working". There are two cases:
- **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.
- **The try block also does real I/O.** `IOException` is still reachable, so the catch
block compiles and simply stops covering the Jackson call.
The second is the common shape in real code — read a request body, parse it — and it is
what runs below.
```
JacksonException extends RuntimeException : true
JacksonException extends IOException : false
-- catch (IOException) around I/O + Jackson --
ESCAPED the IOException handler -> StreamReadException
-- catch (JacksonException) then catch (IOException) --
caught: StreamReadException
-- unchecked exceptions inside a stream --
[{"orderId":1,"customerName":"Alice"}, {"orderId":2,"customerName":"Bob"}]
```
The upside is real too: Jackson calls now compose inside lambdas and streams without a
checked-exception wrapper, as the last line shows.
## Y02 — FAIL_ON_TRAILING_TOKENS
[`Y02TrailingTokens.java`](../src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java)
Off in Jackson 2, on in Jackson 3. Concatenated or double-encoded JSON that used to
parse — reading the first document and discarding the rest — now throws.
```
FAIL_ON_TRAILING_TOKENS default : true
Jackson 3 default -> rejected: MismatchedInputException
2.x behaviour -> accepted: OrderDto[orderId=1]
garbage, default -> rejected: StreamReadException
garbage, relaxed -> accepted: OrderDto[orderId=1]
```
A correctness improvement, but it surfaces as new runtime failures on payloads that
previously "worked", which is a bad thing to discover in production.
## Y03 — where WRITE_DATES_AS_TIMESTAMPS went
[`Y03DateTimeDefaults.java`](../src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java)
Several blog snippets carry `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`
over from Jackson 2. That constant is not on `SerializationFeature` in Jackson 3, so the
code does not compile. It moved to `tools.jackson.databind.cfg.DateTimeFeature` and
defaults to off, so there is nothing to disable.
```
SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : false
DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : false
defaults (ISO-8601):
{"day":"2026-09-15","startsAt":"2026-09-15T10:30:00","recordedAt":"2026-09-15T10:30:00Z","zoned":"2026-09-15T10:30:00Z","length":"PT45M"}
with WRITE_DATES_AS_TIMESTAMPS enabled:
{"day":[2026,9,15],"startsAt":[2026,9,15,10,30],"recordedAt":1789468200.000000000,"zoned":1789468200.000000000,"length":"PT45M"}
numeric form reads back: 2026-09-15
```
Both forms read back, so stored payloads written by an older service still parse.
## Y04 — configuring an immutable mapper
[`Y04ImmutableMapperAndReaders.java`](../src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java)
The Jackson 2 habit of fetching the shared mapper and calling `configure()` on it has no
equivalent. Two replacements: `rebuild()` forks a builder from an existing mapper, and
`reader()`/`writer()` give per-call views.
```
shared : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
rebuilt snake_case : {"first_name":"Ada","last_name":"Lovelace"}
shared unchanged : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
writer view pretty : { "firstName" : "Ada", "lastName" : "Lovelace", "middleName" : null }
reader view strict : rejected (UnrecognizedPropertyException) without touching the shared mapper
```
`ObjectReader` has no `readValue(String, Class)` overload — use `.forType(X.class)`
then `.readValue(json)`.
## Y05 — creator detection
[`Y05CreatorDetection.java`](../src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java)
The comparison post says removing `MapperFeature.AUTO_DETECT_CREATORS` means "any class
relying on a single-argument constructor being detected without an annotation will
quietly fail". Half right: the enum constant is gone, but the behaviour is not.
```
MapperFeature.AUTO_DETECT_CREATORS exists : false
Nearest surviving features : [INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES, DETECT_PARAMETER_NAMES, SORT_CREATOR_PROPERTIES_FIRST]
implicit single-arg ctor : ImplicitOrderId[ord-1]
explicit @JsonCreator : ExplicitOrderId[ord-2]
round-trip via @JsonValue: "ord-3"
```
Annotate with `@JsonCreator` anyway — it is explicit and costs nothing — but do not
budget upgrade time for classes that will not actually break.
## Y06 — RecyclerPool
[`Y06RecyclerPoolTuning.java`](../src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java)
Jackson 3 changed the default buffer pool. The post says to restore the 2.x thread-local
pool if you see a regression, but does not measure it. Whether it helps depends entirely
on your concurrency profile, so measure on your own hardware.
```
default pool : ConcurrentDequePool
cores : 2
--- 1 thread(s), 40000 round-trips each ---
threadLocalPool (2.x default) 301 ms
concurrentDeque (3.x default) 310 ms
nonRecyclingPool (no reuse) 496 ms
--- 8 thread(s), 40000 round-trips each ---
threadLocalPool (2.x default) 451 ms
concurrentDeque (3.x default) 450 ms
nonRecyclingPool (no reuse) 808 ms
```
On this 2-core container the two pools land within noise of each other in both shapes,
and which one wins moves between runs — so the honest reading is that there is no default
winner here and the post's advice is worth testing rather than applying blind. Re-run this
a few times before concluding anything; the figures above are a single shot.
What *is* stable across runs is the third line: disabling recycling entirely is
consistently the slowest, and by a wide margin under concurrency. That is the useful
negative control — it confirms the pool is doing real work, so the choice between the two
pooling strategies is a tuning decision rather than a correctness one.

View File

@@ -0,0 +1,2 @@
{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
Mechanical Keyboard

View File

@@ -0,0 +1,3 @@
configured : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09"}
defaults : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09","note":null}
ObjectMapper set*() methods in Jackson 3: 0

View File

@@ -0,0 +1,3 @@
1. data binding : Order[orderId=1001, status=SHIPPED]
2. tree model : orderId=1001 status=SHIPPED
3. streaming : orderId=1001 status=SHIPPED

View File

@@ -0,0 +1,8 @@
{"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
file : {"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
pretty :
{
"articleId" : 1,
"title" : "Jackson Deep Dive",
"tags" : [ "java", "json" ]
}

View File

@@ -0,0 +1,3 @@
from String : Jackson Deep Dive
from File : 1
from Stream : [java, json]

View File

@@ -0,0 +1,6 @@
size : 2
element class : Article
first title : First
raw element : LinkedHashMap <- not Article
cast fails : ClassCastException, as expected
map value : Nine

View File

@@ -0,0 +1,3 @@
POJO : {"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
record : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
ordered : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}

View File

@@ -0,0 +1,3 @@
{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
Wireless Keyboard
round-trip equal: true

View File

@@ -0,0 +1,5 @@
present : {"customerName":"Alice","middleName":"Marie"}
empty : {"customerName":"Bob","middleName":null}
absent : {"customerName":"Bob"}
isPresent: true
missing -> Optional.empty (null? false)

View File

@@ -0,0 +1,4 @@
Circle with radius: 5.0
Rectangle 10.0 x 4.0
lossy : [{"radius":5.0},{"width":10.0,"height":4.0}]
correct : [{"shapeType":"circle","radius":5.0},{"shapeType":"rectangle","width":10.0,"height":4.0}]

View File

@@ -0,0 +1,3 @@
Circle -> {"shapeType":"circle","radius":5.0} -> Circle[radius=5.0]
Rectangle -> {"shapeType":"rectangle","width":10.0,"height":4.0} -> Rectangle[width=10.0, height=4.0]
Triangle -> {"shapeType":"triangle","base":3.0,"height":6.0} -> Triangle[base=3.0, height=6.0]

View File

@@ -0,0 +1,4 @@
rename : {"order_id":1001,"customer_name":"Alice"}
read back : OrderSummary[orderId=1001, customerName=Alice]
ignore : {"username":"alice"}
read back : passwordHash=null

View File

@@ -0,0 +1,3 @@
NON_NULL : {"productName":"Keyboard"}
NON_EMPTY : {"productName":"Keyboard"}
formats : {"invoiceId":500,"defaultDate":"2026-04-09","ukStyleDate":"09/04/2026","totalAmount":"199.99"}

View File

@@ -0,0 +1,6 @@
alias {"q":"jackson"} -> jackson
alias {"query":"jackson"} -> jackson
alias {"search_term":"jackson"} -> jackson
default mapper : LenientResponse[status=OK, message=done]
strict mapper : UnrecognizedPropertyException (as expected)
strict + anno : OptedOutResponse[status=OK, message=done]

View File

@@ -0,0 +1,5 @@
creator : ImmutablePoint(x=3.5, y=7.2)
round-trip: {"x":3.5,"y":7.2}
unwrapped : {"street":"123 Main St","city":"Springfield","customerName":"Alice"}
any-setter: {surprise=1, another=[true, false]}
any-getter: {"knownField":"a","surprise":1,"another":[true,false]}

View File

@@ -0,0 +1,5 @@
serialised : {"amount":20.00,"currency":"USD"}
amount : 20.0 (scale lost)
amount exact : 20.00 (scale preserved)
missing field: Money[amount=0, currencyCode=EUR]
no module : {"amount":19.999,"currencyCode":"usd"}

View File

@@ -0,0 +1,2 @@
with mixin : {"city":"Springfield","street":"123 Main St","zip":"12345"}
without mixin: {"city":"Springfield","internalTrackingCode":"INTERNAL-X99","postalCode":"12345","street":"123 Main St"}

View File

@@ -0,0 +1,3 @@
custom : {"assignee":"u-42","title":"Fix build"}
default : {"assignee":{"value":"u-42"},"title":"Fix build"}
base class: tools.jackson.databind.ValueSerializer

View File

@@ -0,0 +1,20 @@
--- 1. single element: discriminator present ---
{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1}
--- 2. BROKEN: writeValueAsString(List) drops paymentType ---
[{"amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
--- 3. FIX A: writerFor(TypeReference) ---
[ {
"paymentType" : "credit_card",
"amountDue" : 99.99,
"cardNetwork" : "VISA",
"cardNumberLastFour" : "4242",
"paymentId" : 1
}, {
"paymentType" : "bank_transfer",
"amountDue" : 250.0,
"bankAccountIban" : "GB29NWBK60161331926819",
"bankName" : "National Bank",
"paymentId" : 2
} ]
--- 4. FIX B: a typed array carries its component type ---
[{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"paymentType":"bank_transfer","amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]

View File

@@ -0,0 +1,4 @@
Card ending: 4242
Bank: National Bank
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first

View File

@@ -0,0 +1,7 @@
PROPERTY : {"kind":"card","amountDue":99.0}
WRAPPER_OBJECT : {"card":{"amountDue":99.0}}
WRAPPER_ARRAY : ["card",{"amountDue":99.0}]
EXISTING_PROPERTY : {"kind":"card","amountDue":99.0}
read PROPERTY -> PropCard[amountDue=99.0]
read WRAPPER_OBJECT -> WrapObjCard[amountDue=99.0]
read WRAPPER_ARRAY -> WrapArrCard[amountDue=99.0]

View File

@@ -0,0 +1,3 @@
unknown logical name -> InvalidTypeIdException
attacker-supplied class name -> InvalidTypeIdException
missing discriminator -> InvalidTypeIdException

View File

@@ -0,0 +1,2 @@
ERROR: Database connection failed
Total errors found: 1

View File

@@ -0,0 +1,4 @@
records written : 1000000
file size : 30 MB
elapsed : 129 ms
heap delta : 0 MB <- the document is never held in memory

View File

@@ -0,0 +1,8 @@
Customer: Alice
KB-01 x2
MS-42 x1
Has discount: false
path(missing) : (class MissingNode)
get(missing) : null
deep path : '<default>'
treeToValue : CustomerRecord[name=Alice, tier=gold]

View File

@@ -0,0 +1,5 @@
input file : 20 MB, 200000 entries
data binding (readValue) errors=4000 524 ms heap delta 47 MB
tree model (readTree) errors=4000 341 ms heap delta 102 MB
streaming (JsonParser) errors=4000 78 ms heap delta 1 MB

View File

@@ -0,0 +1,3 @@
email : EmailNotification[a@example.com]
sms : SmsNotification[+441234567890]
attack: rejected with InvalidTypeIdException

View File

@@ -0,0 +1,14 @@
--- tools.jackson.databind.ObjectMapper ---
enableDefaultTyping ABSENT
activateDefaultTyping ABSENT
setSerializationInclusion ABSENT
registerModule ABSENT
addMixIn ABSENT
total set*() mutators: 0
--- tools.jackson.databind.json.JsonMapper.Builder ---
activateDefaultTyping present
deactivateDefaultTyping present
polymorphicTypeValidator present
changeDefaultPropertyInclusion present
serializationInclusion ABSENT

View File

@@ -0,0 +1,3 @@
allowed written : {"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$Envelope","body":{"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$SafePayload","note":"ok"}}
allowed read : SafePayload[ok]
rogue : rejected with InvalidTypeIdException

View File

@@ -0,0 +1,11 @@
--- Jackson 3 defaults ---
max nesting depth : 500
max number length : 1000
max string length : 100000000
max name length : 50000
max doc length : -1 (-1 = unlimited)
1200-deep nesting, default limits -> rejected (StreamConstraintsException)
20-deep nesting, strict limits -> rejected (StreamConstraintsException)
5-deep nesting, strict limits -> accepted
3KB string, strict limits -> rejected (StreamConstraintsException)

View File

@@ -0,0 +1,4 @@
as Object : {action=ship, quantity=3, extra={nested=[1, 2]}}
runtime type: java.util.LinkedHashMap <- a plain Map, no arbitrary class was instantiated
as DTO : MyRequestDto[action=ship, quantity=3]
bad input : rejected with InvalidFormatException

View File

@@ -0,0 +1,9 @@
JacksonException extends RuntimeException : true
JacksonException extends IOException : false
-- catch (IOException) around I/O + Jackson --
ESCAPED the IOException handler -> StreamReadException
-- catch (JacksonException) then catch (IOException) --
caught: StreamReadException
-- unchecked exceptions inside a stream --
[{"orderId":1,"customerName":"Alice"}, {"orderId":2,"customerName":"Bob"}]

View File

@@ -0,0 +1,6 @@
FAIL_ON_TRAILING_TOKENS default : true
Jackson 3 default -> rejected: MismatchedInputException
2.x behaviour -> accepted: OrderDto[orderId=1]
garbage, default -> rejected: StreamReadException
garbage, relaxed -> accepted: OrderDto[orderId=1]

View File

@@ -0,0 +1,8 @@
SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : false
DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : false
defaults (ISO-8601):
{"day":"2026-09-15","startsAt":"2026-09-15T10:30:00","recordedAt":"2026-09-15T10:30:00Z","zoned":"2026-09-15T10:30:00Z","length":"PT45M"}
with WRITE_DATES_AS_TIMESTAMPS enabled:
{"day":[2026,9,15],"startsAt":[2026,9,15,10,30],"recordedAt":1789468200.000000000,"zoned":1789468200.000000000,"length":"PT45M"}
numeric form reads back: 2026-09-15

View File

@@ -0,0 +1,5 @@
shared : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
rebuilt snake_case : {"first_name":"Ada","last_name":"Lovelace"}
shared unchanged : {"firstName":"Ada","lastName":"Lovelace","middleName":null}
writer view pretty : { "firstName" : "Ada", "lastName" : "Lovelace", "middleName" : null }
reader view strict : rejected (UnrecognizedPropertyException) without touching the shared mapper

View File

@@ -0,0 +1,6 @@
MapperFeature.AUTO_DETECT_CREATORS exists : false
Nearest surviving features : [INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES, DETECT_PARAMETER_NAMES, SORT_CREATOR_PROPERTIES_FIRST]
implicit single-arg ctor : ImplicitOrderId[ord-1]
explicit @JsonCreator : ExplicitOrderId[ord-2]
round-trip via @JsonValue: "ord-3"

View File

@@ -0,0 +1,13 @@
default pool : ConcurrentDequePool
cores : 2
--- 1 thread(s), 40000 round-trips each ---
threadLocalPool (2.x default) 301 ms
concurrentDeque (3.x default) 310 ms
nonRecyclingPool (no reuse) 496 ms
--- 8 thread(s), 40000 round-trips each ---
threadLocalPool (2.x default) 451 ms
concurrentDeque (3.x default) 450 ms
nonRecyclingPool (no reuse) 808 ms

60
docs/part0-setup.md Normal file
View File

@@ -0,0 +1,60 @@
# Part 0 — Jackson 101: setup and the ObjectMapper lifecycle
Post: <https://ankurm.com/jackson-java-tutorial/>
## A01 — first round-trip
[`A01FirstRoundTrip.java`](../src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java)
A plain POJO out to JSON and back, using `JsonMapper.builder().build()` in place of
`new ObjectMapper()`. Note there is no `throws` clause: Jackson 3 exceptions are unchecked.
```
{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
Mechanical Keyboard
```
The property order is **alphabetical**, not declaration order — the post shows
`productId` first. That is not a Jackson 3 change; it is how getter-based POJOs have
always been introspected. Records behave differently. See
[Part 1 / B04](part1-objectmapper.md#b04--property-ordering).
## A02 — the shared mapper
[`A02SharedMapperConfiguration.java`](../src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java)
The post's central rule — build once, share everywhere — is enforced by the API in
Jackson 3 rather than left to discipline. Three of the four lines people copy from
Jackson 2 configuration are unnecessary or invalid here:
| Jackson 2 line | Status in Jackson 3 |
|---|---|
| `.registerModule(new JavaTimeModule())` | Delete. Built in; the class does not exist under `tools.jackson`. |
| `.registerModule(new Jdk8Module())` | Delete. Same. |
| `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)` | **Does not compile.** Moved to `DateTimeFeature`, and already off. |
| `.configure(FAIL_ON_UNKNOWN_PROPERTIES, false)` | Redundant — already the default. |
| `.setSerializationInclusion(NON_NULL)` | Replaced by `.changeDefaultPropertyInclusion(...)`. |
```
configured : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09"}
defaults : {"invoiceId":500,"customerName":"Alice","issuedOn":"2026-04-09","note":null}
ObjectMapper set*() methods in Jackson 3: 0
```
The last line is the point: `tools.jackson.databind.ObjectMapper` has **zero** `set*`
methods. Reconfiguring a shared mapper is not discouraged in Jackson 3, it is impossible.
## A03 — the three processing models
[`A03ThreeProcessingModels.java`](../src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java)
The same payload read by data binding, the tree model and the streaming parser.
```
1. data binding : Order[orderId=1001, status=SHIPPED]
2. tree model : orderId=1001 status=SHIPPED
3. streaming : orderId=1001 status=SHIPPED
```
For what each approach costs on a real file, see
[Part 6 / G04](part6-streaming.md#g04--the-decision-table-measured).

View File

@@ -0,0 +1,69 @@
# Part 1 — ObjectMapper: reading and writing
Post: <https://ankurm.com/jackson-objectmapper-guide/>
## B01 — writing
[`B01WriteJson.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java)
String, File and pretty-printed output.
```
{"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
file : {"articleId":1,"title":"Jackson Deep Dive","tags":["java","json"]}
pretty :
{
"articleId" : 1,
"title" : "Jackson Deep Dive",
"tags" : [ "java", "json" ]
}
```
## B02 — reading
[`B02ReadJson.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java)
String, File and InputStream. The post's `readValue(new URL(...), ...)` example is
deliberately not reproduced — it makes a live network call, so it could not be part of
a reproducible run. The `InputStream` form is what an HTTP client hands you in practice.
```
from String : Jackson Deep Dive
from File : 1
from Stream : [java, json]
```
## B03 — generic collections
[`B03GenericCollections.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java)
`TypeReference` lives in `tools.jackson.core.type` in Jackson 3. The example also runs
the wrong version — `readValue(json, List.class)` — to show what it actually produces.
```
size : 2
element class : Article
first title : First
raw element : LinkedHashMap <- not Article
cast fails : ClassCastException, as expected
map value : Nine
```
The failure is deferred, not immediate: the call succeeds and returns a
`List<LinkedHashMap>`. You find out at the first cast, often far from the parse site.
## B04 — property ordering
[`B04PropertyOrdering.java`](../src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java)
Beyond the post, but it explains why your first output does not look like the article's.
```
POJO : {"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
record : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
ordered : {"productId":1,"productName":"Mechanical Keyboard","listPrice":79.99}
```
Getter-based POJOs serialise alphabetically; records follow declaration order;
`@JsonPropertyOrder` overrides both. Worth pinning explicitly if the JSON is
cached, signed, or diffed against a fixture.

70
docs/part2-modern-java.md Normal file
View File

@@ -0,0 +1,70 @@
# Part 2 — Records, Optionals and sealed types
Post: <https://ankurm.com/jackson-java-records-optionals/>
The single biggest simplification in Jackson 3 lands here. All three features are in
core: the project's [`pom.xml`](../pom.xml) has one dependency, no
`jackson-datatype-jdk8`, no `jackson-module-parameter-names`, and no `-parameters`
compiler argument.
## C01 — records
[`C01RecordRoundTrip.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java)
```
{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
Wireless Keyboard
round-trip equal: true
```
The post's own dependency block here is wrong twice over: it suggests
`com.fasterxml.jackson.module:jackson-module-parameter-names:3.1.2`, which is both an
artifact Jackson 3 does not need and a version that does not exist at that coordinate.
## C02 — Optional
[`C02OptionalFields.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java)
```
present : {"customerName":"Alice","middleName":"Marie"}
empty : {"customerName":"Bob","middleName":null}
absent : {"customerName":"Bob"}
isPresent: true
missing -> Optional.empty (null? false)
```
Two behaviours worth committing to memory: an empty `Optional` serialises as `null`
unless you ask for `NON_ABSENT`, and a *missing* property deserialises to
`Optional.empty()` rather than to `null`, so the field is never null.
## C03 — sealed types with an explicit registry
[`C03SealedWithSubTypes.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java)
```
Circle with radius: 5.0
Rectangle 10.0 x 4.0
lossy : [{"radius":5.0},{"width":10.0,"height":4.0}]
correct : [{"shapeType":"circle","radius":5.0},{"shapeType":"rectangle","width":10.0,"height":4.0}]
```
Note the `lossy` line. Serialising a `List<Shape>` drops the `shapeType` discriminator,
because a `List` gives Jackson no element type to dispatch on. This is the same defect
covered at length in [Part 5](part5-polymorphic.md).
## C04 — sealed auto-discovery
[`C04SealedAutoDiscovery.java`](../src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java)
Beyond the post: Jackson 3 reads the `permits` clause, so `@JsonSubTypes` can be dropped
entirely when each permitted type carries `@JsonTypeName`. There is no `@JsonSubTypes`
anywhere in that file, and a third subtype added later just works.
```
Circle -> {"shapeType":"circle","radius":5.0} -> Circle[radius=5.0]
Rectangle -> {"shapeType":"rectangle","width":10.0,"height":4.0} -> Rectangle[width=10.0, height=4.0]
Triangle -> {"shapeType":"triangle","base":3.0,"height":6.0} -> Triangle[base=3.0, height=6.0]
```
In Jackson 2 the `@JsonSubTypes` registry had to be maintained in parallel with
`permits`, and drifted whenever someone added a case.

74
docs/part3-annotations.md Normal file
View File

@@ -0,0 +1,74 @@
# Part 3 — Annotations
Post: <https://ankurm.com/jackson-annotations-guide/>
Every `@Json*` annotation is imported from `com.fasterxml.jackson.annotation`, even in
Jackson 3. `jackson-annotations` deliberately keeps the old group ID and package so a
single copy can serve Jackson 2 and Jackson 3 code on one classpath — the
[migration repo's coexistence module](https://ankurm.com/git.app/asmhatre/jackson2-to-3-migration)
proves that with both majors loaded at once.
## D01 — @JsonProperty and @JsonIgnore
[`D01RenameAndIgnore.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java)
```
rename : {"order_id":1001,"customer_name":"Alice"}
read back : OrderSummary[orderId=1001, customerName=Alice]
ignore : {"username":"alice"}
read back : passwordHash=null
```
`@JsonIgnore` is bidirectional — the last line shows an injected `passwordHash` in the
input being discarded, which is the security-relevant half people forget.
## D02 — @JsonInclude and @JsonFormat
[`D02InclusionAndFormat.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java)
```
NON_NULL : {"productName":"Keyboard"}
NON_EMPTY : {"productName":"Keyboard"}
formats : {"invoiceId":500,"defaultDate":"2026-04-09","ukStyleDate":"09/04/2026","totalAmount":"199.99"}
```
`defaultDate` carries no annotation and still comes out as `2026-04-09`. The post's
claim that "without `@JsonFormat`, Jackson writes `LocalDate` as a numeric array" was
true in Jackson 2; in Jackson 3 ISO-8601 is the default and the annotation is only
needed for a non-standard pattern such as `ukStyleDate`.
## D03 — @JsonAlias and unknown fields
[`D03AliasAndUnknownFields.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java)
```
alias {"q":"jackson"} -> jackson
alias {"query":"jackson"} -> jackson
alias {"search_term":"jackson"} -> jackson
default mapper : LenientResponse[status=OK, message=done]
strict mapper : UnrecognizedPropertyException (as expected)
strict + anno : OptedOutResponse[status=OK, message=done]
```
The post frames `@JsonIgnoreProperties(ignoreUnknown = true)` as the per-class
alternative to disabling `FAIL_ON_UNKNOWN_PROPERTIES` globally. In Jackson 3 that
feature is already off, so the default mapper tolerates the extra field with no
annotation. The annotation earns its keep only on a mapper where you have deliberately
re-enabled strictness — which is the `strict` case above.
## D04 — creators, unwrapping and catch-alls
[`D04CreatorsAndUnwrapping.java`](../src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java)
`@JsonUnwrapped` appears in the post's summary table but is never demonstrated;
`@JsonAnyGetter`/`@JsonAnySetter` are not in the post at all, and are the cleanest way
to keep fields you did not model instead of silently dropping them.
```
creator : ImmutablePoint(x=3.5, y=7.2)
round-trip: {"x":3.5,"y":7.2}
unwrapped : {"street":"123 Main St","city":"Springfield","customerName":"Alice"}
any-setter: {surprise=1, another=[true, false]}
any-getter: {"knownField":"a","surprise":1,"another":[true,false]}
```

72
docs/part4-custom.md Normal file
View File

@@ -0,0 +1,72 @@
# Part 4 — Custom serialisers, deserialisers and mix-ins
Post: <https://ankurm.com/jackson-custom-serializer-mixin/>
This is the part with the most API churn. The post's code is Jackson 2 throughout.
| Jackson 2 | Jackson 3 |
|---|---|
| `JsonSerializer<T>` | `ValueSerializer<T>` |
| `JsonDeserializer<T>` | `ValueDeserializer<T>` |
| `SerializerProvider` | `SerializationContext` |
| `com.fasterxml.jackson.databind.ser.std.StdSerializer` | `tools.jackson.databind.ser.std.StdSerializer` |
| `gen.writeNumberField` / `writeStringField` | `gen.writeNumberProperty` / `writeStringProperty` |
| `p.getCodec().readTree(p)` | `ctxt.readTree(p)` |
| `throws IOException` | removed — exceptions are unchecked |
| `new SimpleModule(name, Version)` | `new SimpleModule(name)` |
| `mapper.registerModule(m)` | `builder.addModule(m)` |
| `mapper.addMixIn(a, b)` | `builder.addMixIn(a, b)` |
## E01 / E02 — the handlers
[`E01MoneyValueSerializer.java`](../src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java) ·
[`E02MoneyValueDeserializer.java`](../src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java) ·
[`Money.java`](../src/main/java/com/ankurm/jackson3/part4custom/Money.java)
The deserialiser uses `path()` rather than `get()`. The post's version calls
`rootNode.get("amount").decimalValue()`, which throws `NullPointerException` on
`{"currency":"USD"}`. There is a second trap: in Jackson 3, a bare `decimalValue()` on
a `MissingNode` throws, where Jackson 2 returned `BigDecimal.ZERO`. The defaulting
overload `decimalValue(BigDecimal.ZERO)` is what you want.
## E03 — registration
[`E03SimpleModuleRegistration.java`](../src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java)
```
serialised : {"amount":20.00,"currency":"USD"}
amount : 20.0 (scale lost)
amount exact : 20.00 (scale preserved)
missing field: Money[amount=0, currencyCode=EUR]
no module : {"amount":19.999,"currencyCode":"usd"}
```
The post prints `20.00` for the deserialised amount. You get `20.0` — Jackson parses the
literal as a double before handing it over, so the scale is gone. If scale matters, and
for money it does, enable `USE_BIG_DECIMAL_FOR_FLOATS`, as the third line shows.
## E04 — mix-ins
[`E04MixinAnnotations.java`](../src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java)
```
with mixin : {"city":"Springfield","street":"123 Main St","zip":"12345"}
without mixin: {"city":"Springfield","internalTrackingCode":"INTERNAL-X99","postalCode":"12345","street":"123 Main St"}
```
The second line is the proof that nothing was modified at the bytecode level: a mapper
built without the mix-in still sees `internalTrackingCode` and `postalCode`.
## E05 — ValueSerializer directly
[`E05ValueSerializerDirect.java`](../src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java)
Beyond the post. `StdSerializer` kept its name, which hides the rename; extending
`ValueSerializer` directly makes it obvious, and is the leaner form for a simple wrapper.
```
custom : {"assignee":"u-42","title":"Fix build"}
default : {"assignee":{"value":"u-42"},"title":"Fix build"}
base class: tools.jackson.databind.ValueSerializer
```

111
docs/part5-polymorphic.md Normal file
View File

@@ -0,0 +1,111 @@
# Part 5 — Polymorphic deserialisation
Post: <https://ankurm.com/jackson-polymorphic-deserialization/>
This part contains the most consequential correction in the whole repo, so it gets the
most space.
## The defect
The post's "Serialising a Mixed List" section shows:
```java
List<PaymentMethod> payments = List.of(card, bank);
String jsonOutput = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(payments);
```
and prints output containing `"paymentType" : "credit_card"`.
It does not produce that. `writeValueAsString(Object)` sees only the runtime class of
the argument — `ImmutableCollections.ListN` — which carries no element type. Without a
declared element type Jackson never engages the polymorphic type serialiser, and the
discriminator is silently omitted from every element. No exception, no warning.
The consequence is not cosmetic. That JSON cannot be read back: the deserialiser has
no type id to dispatch on and throws `InvalidTypeIdException`. A service that writes
with the post's code and reads with the post's code does not round-trip.
## F01 — the defect and both fixes
[`F01SerialiseMixedList.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java)
```
--- 1. single element: discriminator present ---
{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1}
--- 2. BROKEN: writeValueAsString(List) drops paymentType ---
[{"amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
--- 3. FIX A: writerFor(TypeReference) ---
[ {
"paymentType" : "credit_card",
"amountDue" : 99.99,
"cardNetwork" : "VISA",
"cardNumberLastFour" : "4242",
"paymentId" : 1
}, {
"paymentType" : "bank_transfer",
"amountDue" : 250.0,
"bankAccountIban" : "GB29NWBK60161331926819",
"bankName" : "National Bank",
"paymentId" : 2
} ]
--- 4. FIX B: a typed array carries its component type ---
[{"paymentType":"credit_card","amountDue":99.99,"cardNetwork":"VISA","cardNumberLastFour":"4242","paymentId":1},{"paymentType":"bank_transfer","amountDue":250.0,"bankAccountIban":"GB29NWBK60161331926819","bankName":"National Bank","paymentId":2}]
```
Block 1 shows a single element serialising correctly, which is why this is easy to miss
in a unit test that only checks one object. Block 2 is the collection, discriminator
absent. Blocks 3 and 4 are the two fixes:
- `mapper.writerFor(new TypeReference<List<PaymentMethod>>() {})` — declares the element
type on the writer.
- `payments.toArray(new PaymentMethod[0])` — an array carries its component type at
runtime, so no extra declaration is needed.
## F02 — round-trip proof
[`F02DeserialiseMixedList.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java)
```
Card ending: 4242
Bank: National Bank
lossy JSON round-trip -> InvalidTypeIdException
correct JSON round-trip -> 2 payments, CreditCardPayment first
```
Deserialisation itself works exactly as the post describes; it is the serialisation side
that is wrong. The middle two lines are the demonstration: lossy output fails, correctly
written output survives.
## F03 — the four include strategies
[`F03IncludeStrategies.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java)
The post gives these as a table. Here they are executed, with the read-back to confirm
each wire format is symmetric.
```
PROPERTY : {"kind":"card","amountDue":99.0}
WRAPPER_OBJECT : {"card":{"amountDue":99.0}}
WRAPPER_ARRAY : ["card",{"amountDue":99.0}]
EXISTING_PROPERTY : {"kind":"card","amountDue":99.0}
read PROPERTY -> PropCard[amountDue=99.0]
read WRAPPER_OBJECT -> WrapObjCard[amountDue=99.0]
read WRAPPER_ARRAY -> WrapArrCard[amountDue=99.0]
```
`EXISTING_PROPERTY` needs `visible = true` and a real field of that name on the type;
without it the discriminator is written but not populated back onto the object.
## F04 — bad discriminators
[`F04UnknownTypeId.java`](../src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java)
```
unknown logical name -> InvalidTypeIdException
attacker-supplied class name -> InvalidTypeIdException
missing discriminator -> InvalidTypeIdException
```
All three fail the same way, which is the reassuring answer: an unregistered logical
name, an attacker-supplied fully qualified class name, and a missing discriminator are
indistinguishable to the type resolver, and none of them instantiate anything.

80
docs/part6-streaming.md Normal file
View File

@@ -0,0 +1,80 @@
# Part 6 — Streaming API and the tree model
Post: <https://ankurm.com/jackson-streaming-api-jsonnode/>
Renames that break the post's code verbatim:
| Jackson 2 | Jackson 3 |
|---|---|
| `com.fasterxml.jackson.core.JsonFactory` | `tools.jackson.core.json.JsonFactory` (note the extra `.json`) |
| `parser.getCurrentName()` | `parser.currentName()` |
| `parser.getText()` | `parser.getString()` |
| `JsonToken.FIELD_NAME` | `JsonToken.PROPERTY_NAME` |
| `gen.writeNumberField` / `writeStringField` | `gen.writeNumberProperty` / `writeStringProperty` |
| `node.asText()` | `node.asString()` |
| `factory.createParser(file)` | `factory.createParser(ObjectReadContext.empty(), file)` |
The comparison post's performance section gives the import as
`tools.jackson.core.JsonFactory`, which does not resolve — the class is in the
`.json` subpackage.
## G01 — filtering a large array
[`G01StreamingParserFilter.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java)
```
ERROR: Database connection failed
Total errors found: 1
```
## G02 — writing a million records
[`G02StreamingGenerator.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java)
The post's loop, kept at its full 1,000,000 iterations, with the heap delta measured so
"constant memory usage" is a number.
```
records written : 1000000
file size : 30 MB
elapsed : 129 ms
heap delta : 0 MB <- the document is never held in memory
```
## G03 — the tree model
[`G03TreeModelNavigation.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java)
```
Customer: Alice
KB-01 x2
MS-42 x1
Has discount: false
path(missing) : (class MissingNode)
get(missing) : null
deep path : '<default>'
treeToValue : CustomerRecord[name=Alice, tier=gold]
```
The `path(missing)` / `get(missing)` pair is the whole argument for `path()`: it returns
a `MissingNode` that chains safely, where `get()` returns `null` and the next call NPEs.
## G04 — the decision table, measured
[`G04ThreeApproachesMeasured.java`](../src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java)
The post closes with a table asserting relative memory and verbosity. This runs the same
filter three ways over a generated 20 MB file.
```
input file : 20 MB, 200000 entries
data binding (readValue) errors=4000 524 ms heap delta 47 MB
tree model (readTree) errors=4000 341 ms heap delta 102 MB
streaming (JsonParser) errors=4000 78 ms heap delta 1 MB
```
Single-shot measurements on a 2-core container, not JMH — treat the ordering as the
result, not the absolute figures. Two things do stand out and are stable across runs:
the tree model costs roughly twice the heap of data binding for the same document, and
streaming is the only approach whose heap does not scale with input size.

116
docs/part7-security.md Normal file
View File

@@ -0,0 +1,116 @@
# Part 7 — Security
Post: <https://ankurm.com/jackson-security-best-practices/>
## H01 — the safe pattern
[`H01SafePolymorphismByAnnotation.java`](../src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java)
`@JsonTypeInfo(use = Id.NAME)` plus an explicit `@JsonSubTypes` registry. The permitted
set is fixed at compile time, so no payload can introduce a class of its own.
```
email : EmailNotification[a@example.com]
sms : SmsNotification[+441234567890]
attack: rejected with InvalidTypeIdException
```
This is the recommendation, and it is correct. Reach for anything below only if this
cannot express your model.
## H02 — what survived into Jackson 3
[`H02DefaultTypingRemoved.java`](../src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java)
The post's remediation snippet is Jackson 2:
```java
ObjectMapper mapper = new ObjectMapper();
mapper.activateDefaultTyping(safeTypeValidator, ObjectMapper.DefaultTyping.NON_FINAL, ...);
```
Neither line compiles against Jackson 3. The mapper has no mutators at all, and
`DefaultTyping` is no longer nested inside `ObjectMapper`.
```
--- tools.jackson.databind.ObjectMapper ---
enableDefaultTyping ABSENT
activateDefaultTyping ABSENT
setSerializationInclusion ABSENT
registerModule ABSENT
addMixIn ABSENT
total set*() mutators: 0
--- tools.jackson.databind.json.JsonMapper.Builder ---
activateDefaultTyping present
deactivateDefaultTyping present
polymorphicTypeValidator present
changeDefaultPropertyInclusion present
serializationInclusion ABSENT
```
A second correction, from the migration repo rather than this one: both guides state
that `enableDefaultTyping()` was "removed in 2.16". It is still present on
`ObjectMapper` in Jackson 2.22.1, deprecated. That changes the migration story — a
Jackson 2 codebase can still be compiling against it today, so Jackson 3 is where it
finally breaks, not 2.16.
## H03 — a provable allowlist
[`H03PolymorphicTypeValidatorAllowlist.java`](../src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java)
The post's intent in working Jackson 3 form, with the negative test the post's own AI
prompt asks for.
```
allowed written : {"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$Envelope","body":{"@class":"com.ankurm.jackson3.part7security.H03PolymorphicTypeValidatorAllowlist$SafePayload","note":"ok"}}
allowed read : SafePayload[ok]
rogue : rejected with InvalidTypeIdException
```
One trap the post does not mention: with `DefaultTyping.NON_FINAL`, Jackson writes a
type id for the **root** object too, so the root class must be allowlisted as well.
Allowlisting only the payload base type makes the happy path fail, not just the attack
path — which reads like a bug in your validator when it is actually correct behaviour.
## H04 — resource limits
[`H04StreamReadConstraints.java`](../src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java)
Beyond the post. Gadget attacks are not the only deserialisation risk: a few hundred
bytes of nested brackets can exhaust the stack. Jackson 3 ships defaults for this and
they are tunable.
```
--- Jackson 3 defaults ---
max nesting depth : 500
max number length : 1000
max string length : 100000000
max name length : 50000
max doc length : -1 (-1 = unlimited)
1200-deep nesting, default limits -> rejected (StreamConstraintsException)
20-deep nesting, strict limits -> rejected (StreamConstraintsException)
5-deep nesting, strict limits -> accepted
3KB string, strict limits -> rejected (StreamConstraintsException)
```
The 100 MB default string limit and unlimited document length are generous for a
public endpoint. Tighten both if you accept untrusted JSON.
## H05 — Object.class
[`H05NeverDeserialiseIntoObject.java`](../src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java)
```
as Object : {action=ship, quantity=3, extra={nested=[1, 2]}}
runtime type: java.util.LinkedHashMap <- a plain Map, no arbitrary class was instantiated
as DTO : MyRequestDto[action=ship, quantity=3]
bad input : rejected with InvalidFormatException
```
Worth being precise here, because the post is slightly alarming about it: on a default
Jackson 3 mapper, `readValue(json, Object.class)` yields a plain `LinkedHashMap`. No
arbitrary class is instantiated. The danger returns only once default typing is active
(H03). The rule still holds — target a specific type and you get input validation as a
side effect, as the last line shows.

56
pom.xml Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm</groupId>
<artifactId>jackson3-by-example</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>jackson3-by-example</name>
<description>Runnable companion code for the Jackson 3 series on ankurm.com</description>
<properties>
<!-- Jackson 3 requires Java 17+. Verified on Temurin 21.0.5. -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jackson.version>3.2.1</jackson.version>
</properties>
<dependencyManagement>
<dependencies>
<!-- The BOM aligns jackson-core, jackson-databind AND the jackson-annotations
version that Jackson 3 expects. Import it instead of pinning each artifact. -->
<dependency>
<groupId>tools.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>${jackson.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Jackson 3 lives at tools.jackson.core, NOT com.fasterxml.jackson.core.
jackson-databind pulls in jackson-core and jackson-annotations transitively.
jackson-annotations deliberately stays on the com.fasterxml.jackson.core
group ID (2.22 as of Jackson 3.2.1) so it can be shared with Jackson 2. -->
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.3</version>
</plugin>
</plugins>
</build>
</project>

27
run-all.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Compiles the project and runs every example, writing each program's real output
# to docs/output/<ClassName>.txt. Everything checked into docs/output was produced
# by this script — nothing is hand-written.
set -u
cd "$(dirname "$0")"
mvn -B -q clean compile || { echo "compile failed"; exit 1; }
CP="target/classes:$(mvn -B -q dependency:build-classpath -Dmdep.outputFile=/dev/stdout -DincludeScope=runtime 2>/dev/null | tail -1)"
mkdir -p docs/output
FAILED=0
for f in $(find src/main/java -name '*.java' | sort); do
CLASS=$(echo "$f" | sed 's|src/main/java/||; s|\.java$||; s|/|.|g')
grep -q 'public static void main' "$f" || continue
SHORT="${CLASS##*.}"
printf '%-52s' "$SHORT"
if java -cp "$CP" "$CLASS" > "docs/output/$SHORT.txt" 2>&1; then
echo "ok"
else
echo "FAILED"; FAILED=1
fi
done
echo
if [ "$FAILED" -eq 0 ]; then echo "All examples ran successfully."; else echo "Some examples failed."; fi
exit $FAILED

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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