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:
18
docs/README.md
Normal file
18
docs/README.md
Normal 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
149
docs/beyond.md
Normal 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.
|
||||
2
docs/output/A01FirstRoundTrip.txt
Normal file
2
docs/output/A01FirstRoundTrip.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"}
|
||||
Mechanical Keyboard
|
||||
3
docs/output/A02SharedMapperConfiguration.txt
Normal file
3
docs/output/A02SharedMapperConfiguration.txt
Normal 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
|
||||
3
docs/output/A03ThreeProcessingModels.txt
Normal file
3
docs/output/A03ThreeProcessingModels.txt
Normal 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
|
||||
8
docs/output/B01WriteJson.txt
Normal file
8
docs/output/B01WriteJson.txt
Normal 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" ]
|
||||
}
|
||||
3
docs/output/B02ReadJson.txt
Normal file
3
docs/output/B02ReadJson.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
from String : Jackson Deep Dive
|
||||
from File : 1
|
||||
from Stream : [java, json]
|
||||
6
docs/output/B03GenericCollections.txt
Normal file
6
docs/output/B03GenericCollections.txt
Normal 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
|
||||
3
docs/output/B04PropertyOrdering.txt
Normal file
3
docs/output/B04PropertyOrdering.txt
Normal 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}
|
||||
3
docs/output/C01RecordRoundTrip.txt
Normal file
3
docs/output/C01RecordRoundTrip.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99}
|
||||
Wireless Keyboard
|
||||
round-trip equal: true
|
||||
5
docs/output/C02OptionalFields.txt
Normal file
5
docs/output/C02OptionalFields.txt
Normal 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)
|
||||
4
docs/output/C03SealedWithSubTypes.txt
Normal file
4
docs/output/C03SealedWithSubTypes.txt
Normal 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}]
|
||||
3
docs/output/C04SealedAutoDiscovery.txt
Normal file
3
docs/output/C04SealedAutoDiscovery.txt
Normal 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]
|
||||
4
docs/output/D01RenameAndIgnore.txt
Normal file
4
docs/output/D01RenameAndIgnore.txt
Normal 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
|
||||
3
docs/output/D02InclusionAndFormat.txt
Normal file
3
docs/output/D02InclusionAndFormat.txt
Normal 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"}
|
||||
6
docs/output/D03AliasAndUnknownFields.txt
Normal file
6
docs/output/D03AliasAndUnknownFields.txt
Normal 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]
|
||||
5
docs/output/D04CreatorsAndUnwrapping.txt
Normal file
5
docs/output/D04CreatorsAndUnwrapping.txt
Normal 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]}
|
||||
5
docs/output/E03SimpleModuleRegistration.txt
Normal file
5
docs/output/E03SimpleModuleRegistration.txt
Normal 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"}
|
||||
2
docs/output/E04MixinAnnotations.txt
Normal file
2
docs/output/E04MixinAnnotations.txt
Normal 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"}
|
||||
3
docs/output/E05ValueSerializerDirect.txt
Normal file
3
docs/output/E05ValueSerializerDirect.txt
Normal 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
|
||||
20
docs/output/F01SerialiseMixedList.txt
Normal file
20
docs/output/F01SerialiseMixedList.txt
Normal 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}]
|
||||
4
docs/output/F02DeserialiseMixedList.txt
Normal file
4
docs/output/F02DeserialiseMixedList.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Card ending: 4242
|
||||
Bank: National Bank
|
||||
lossy JSON round-trip -> InvalidTypeIdException
|
||||
correct JSON round-trip -> 2 payments, CreditCardPayment first
|
||||
7
docs/output/F03IncludeStrategies.txt
Normal file
7
docs/output/F03IncludeStrategies.txt
Normal 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]
|
||||
3
docs/output/F04UnknownTypeId.txt
Normal file
3
docs/output/F04UnknownTypeId.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
unknown logical name -> InvalidTypeIdException
|
||||
attacker-supplied class name -> InvalidTypeIdException
|
||||
missing discriminator -> InvalidTypeIdException
|
||||
2
docs/output/G01StreamingParserFilter.txt
Normal file
2
docs/output/G01StreamingParserFilter.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
ERROR: Database connection failed
|
||||
Total errors found: 1
|
||||
4
docs/output/G02StreamingGenerator.txt
Normal file
4
docs/output/G02StreamingGenerator.txt
Normal 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
|
||||
8
docs/output/G03TreeModelNavigation.txt
Normal file
8
docs/output/G03TreeModelNavigation.txt
Normal 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]
|
||||
5
docs/output/G04ThreeApproachesMeasured.txt
Normal file
5
docs/output/G04ThreeApproachesMeasured.txt
Normal 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
|
||||
3
docs/output/H01SafePolymorphismByAnnotation.txt
Normal file
3
docs/output/H01SafePolymorphismByAnnotation.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
email : EmailNotification[a@example.com]
|
||||
sms : SmsNotification[+441234567890]
|
||||
attack: rejected with InvalidTypeIdException
|
||||
14
docs/output/H02DefaultTypingRemoved.txt
Normal file
14
docs/output/H02DefaultTypingRemoved.txt
Normal 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
|
||||
3
docs/output/H03PolymorphicTypeValidatorAllowlist.txt
Normal file
3
docs/output/H03PolymorphicTypeValidatorAllowlist.txt
Normal 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
|
||||
11
docs/output/H04StreamReadConstraints.txt
Normal file
11
docs/output/H04StreamReadConstraints.txt
Normal 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)
|
||||
4
docs/output/H05NeverDeserialiseIntoObject.txt
Normal file
4
docs/output/H05NeverDeserialiseIntoObject.txt
Normal 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
|
||||
9
docs/output/Y01UncheckedExceptions.txt
Normal file
9
docs/output/Y01UncheckedExceptions.txt
Normal 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"}]
|
||||
6
docs/output/Y02TrailingTokens.txt
Normal file
6
docs/output/Y02TrailingTokens.txt
Normal 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]
|
||||
8
docs/output/Y03DateTimeDefaults.txt
Normal file
8
docs/output/Y03DateTimeDefaults.txt
Normal 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
|
||||
5
docs/output/Y04ImmutableMapperAndReaders.txt
Normal file
5
docs/output/Y04ImmutableMapperAndReaders.txt
Normal 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
|
||||
6
docs/output/Y05CreatorDetection.txt
Normal file
6
docs/output/Y05CreatorDetection.txt
Normal 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"
|
||||
13
docs/output/Y06RecyclerPoolTuning.txt
Normal file
13
docs/output/Y06RecyclerPoolTuning.txt
Normal 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
60
docs/part0-setup.md
Normal 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).
|
||||
69
docs/part1-objectmapper.md
Normal file
69
docs/part1-objectmapper.md
Normal 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
70
docs/part2-modern-java.md
Normal 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
74
docs/part3-annotations.md
Normal 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
72
docs/part4-custom.md
Normal 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
111
docs/part5-polymorphic.md
Normal 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
80
docs/part6-streaming.md
Normal 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
116
docs/part7-security.md
Normal 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.
|
||||
Reference in New Issue
Block a user