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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
target/
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
123
README.md
Normal file
123
README.md
Normal 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
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.
|
||||||
56
pom.xml
Normal file
56
pom.xml
Normal 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
27
run-all.sh
Executable 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
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.StringReader;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — the change most likely to reach production unnoticed.
|
||||||
|
*
|
||||||
|
* In Jackson 2, JsonProcessingException extended IOException, so `catch (IOException)`
|
||||||
|
* caught mapping failures. In Jackson 3, JacksonException extends RuntimeException.
|
||||||
|
*
|
||||||
|
* There are two cases, and only one of them is safe:
|
||||||
|
*
|
||||||
|
* A. The try block contains ONLY Jackson calls. `catch (IOException)` becomes a
|
||||||
|
* COMPILE ERROR — "exception java.io.IOException is never thrown in body of
|
||||||
|
* corresponding try statement". The compiler saves you. Good.
|
||||||
|
*
|
||||||
|
* B. The try block also contains real I/O — reading a file, a socket, a request
|
||||||
|
* body. IOException is still thrown by that code, so the catch block compiles
|
||||||
|
* fine and simply stops catching the Jackson half. This is the dangerous case,
|
||||||
|
* and it is by far the more common shape in real code.
|
||||||
|
*
|
||||||
|
* Case B is what runs below.
|
||||||
|
*/
|
||||||
|
public class Y01UncheckedExceptions {
|
||||||
|
|
||||||
|
public record OrderDto(Long orderId, String customerName) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("JacksonException extends RuntimeException : "
|
||||||
|
+ RuntimeException.class.isAssignableFrom(JacksonException.class));
|
||||||
|
System.out.println("JacksonException extends IOException : "
|
||||||
|
+ IOException.class.isAssignableFrom(JacksonException.class));
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
String malformed = "{\"orderId\": not-json}";
|
||||||
|
|
||||||
|
// CASE B: genuine I/O plus a Jackson call in the same try block. Compiles
|
||||||
|
// cleanly, catches the I/O half, and lets the Jackson half straight through.
|
||||||
|
System.out.println("-- catch (IOException) around I/O + Jackson --");
|
||||||
|
try {
|
||||||
|
try (StringReader reader = new StringReader(malformed)) {
|
||||||
|
reader.read(); // makes IOException reachable
|
||||||
|
mapper.readValue(malformed, OrderDto.class); // no longer covered
|
||||||
|
System.out.println(" unreachable");
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.out.println(" caught by IOException handler");
|
||||||
|
}
|
||||||
|
} catch (JacksonException escaped) {
|
||||||
|
System.out.println(" ESCAPED the IOException handler -> "
|
||||||
|
+ escaped.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The correct Jackson 3 idiom: catch both, separately.
|
||||||
|
System.out.println("-- catch (JacksonException) then catch (IOException) --");
|
||||||
|
try (StringReader reader = new StringReader(malformed)) {
|
||||||
|
reader.read();
|
||||||
|
mapper.readValue(malformed, OrderDto.class);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
System.out.println(" caught: " + e.getClass().getSimpleName());
|
||||||
|
} catch (IOException e) {
|
||||||
|
System.out.println(" I/O error: " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The upside of unchecked: Jackson calls now compose inside lambdas without a
|
||||||
|
// wrapper, which was genuinely painful in Jackson 2.
|
||||||
|
System.out.println("-- unchecked exceptions inside a stream --");
|
||||||
|
List<String> json = Stream.of(new OrderDto(1L, "Alice"), new OrderDto(2L, "Bob"))
|
||||||
|
.map(mapper::writeValueAsString) // no try/catch, no helper needed
|
||||||
|
.toList();
|
||||||
|
System.out.println(" " + json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — a default that flipped between Jackson 2 and Jackson 3.
|
||||||
|
*
|
||||||
|
* FAIL_ON_TRAILING_TOKENS was OFF in Jackson 2 and is ON in Jackson 3. Concatenated
|
||||||
|
* or double-encoded JSON that Jackson 2 quietly accepted (reading the first document
|
||||||
|
* and discarding the rest) now throws. This is a correctness improvement, but it will
|
||||||
|
* surface as new runtime failures on payloads that used to "work".
|
||||||
|
*/
|
||||||
|
public class Y02TrailingTokens {
|
||||||
|
|
||||||
|
public record OrderDto(Long orderId) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper strict = JsonMapper.builder().build(); // Jackson 3 default
|
||||||
|
JsonMapper relaxed = JsonMapper.builder()
|
||||||
|
.disable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS) // Jackson 2 behaviour
|
||||||
|
.build();
|
||||||
|
|
||||||
|
System.out.println("FAIL_ON_TRAILING_TOKENS default : "
|
||||||
|
+ strict.deserializationConfig().isEnabled(
|
||||||
|
DeserializationFeature.FAIL_ON_TRAILING_TOKENS));
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
String concatenated = "{\"orderId\":1} {\"orderId\":2}";
|
||||||
|
|
||||||
|
System.out.println("Jackson 3 default -> " + read(strict, concatenated));
|
||||||
|
System.out.println("2.x behaviour -> " + read(relaxed, concatenated));
|
||||||
|
|
||||||
|
String trailingGarbage = "{\"orderId\":1}garbage";
|
||||||
|
System.out.println("garbage, default -> " + read(strict, trailingGarbage));
|
||||||
|
System.out.println("garbage, relaxed -> " + read(relaxed, trailingGarbage));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String read(JsonMapper mapper, String json) {
|
||||||
|
try {
|
||||||
|
return "accepted: " + mapper.readValue(json, OrderDto.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "rejected: " + e.getClass().getSimpleName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import tools.jackson.databind.cfg.DateTimeFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — where WRITE_DATES_AS_TIMESTAMPS actually lives.
|
||||||
|
*
|
||||||
|
* Several of the blog snippets carry `.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)`
|
||||||
|
* over from Jackson 2. That constant does NOT exist on SerializationFeature in Jackson 3;
|
||||||
|
* the code does not compile. The flag moved to tools.jackson.databind.cfg.DateTimeFeature
|
||||||
|
* and, more usefully, it now defaults to OFF — so ISO-8601 output needs no configuration
|
||||||
|
* at all and there is nothing to disable.
|
||||||
|
*/
|
||||||
|
public class Y03DateTimeDefaults {
|
||||||
|
|
||||||
|
public record Meeting(LocalDate day, LocalDateTime startsAt,
|
||||||
|
Instant recordedAt, ZonedDateTime zoned, Duration length) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("SerializationFeature has WRITE_DATES_AS_TIMESTAMPS : "
|
||||||
|
+ hasSerializationFeature("WRITE_DATES_AS_TIMESTAMPS"));
|
||||||
|
System.out.println("DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS default : "
|
||||||
|
+ JsonMapper.builder().build().serializationConfig()
|
||||||
|
.isEnabled(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS));
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
Meeting meeting = new Meeting(
|
||||||
|
LocalDate.of(2026, 9, 15),
|
||||||
|
LocalDateTime.of(2026, 9, 15, 10, 30),
|
||||||
|
Instant.parse("2026-09-15T10:30:00Z"),
|
||||||
|
ZonedDateTime.parse("2026-09-15T10:30:00Z"),
|
||||||
|
Duration.ofMinutes(45));
|
||||||
|
|
||||||
|
JsonMapper defaults = JsonMapper.builder().build();
|
||||||
|
System.out.println("defaults (ISO-8601):");
|
||||||
|
System.out.println(" " + defaults.writeValueAsString(meeting));
|
||||||
|
|
||||||
|
JsonMapper timestamps = JsonMapper.builder()
|
||||||
|
.enable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||||
|
.build();
|
||||||
|
System.out.println("with WRITE_DATES_AS_TIMESTAMPS enabled:");
|
||||||
|
System.out.println(" " + timestamps.writeValueAsString(meeting));
|
||||||
|
|
||||||
|
// Both forms read back, so an upgrade does not break existing stored payloads.
|
||||||
|
String numeric = timestamps.writeValueAsString(meeting);
|
||||||
|
System.out.println("numeric form reads back: " + defaults.readValue(numeric, Meeting.class).day());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasSerializationFeature(String name) {
|
||||||
|
for (var f : tools.jackson.databind.SerializationFeature.values()) {
|
||||||
|
if (f.name().equals(name)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
|
import tools.jackson.databind.PropertyNamingStrategies;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — how to vary configuration once the mapper is immutable.
|
||||||
|
*
|
||||||
|
* The Jackson 2 habit of grabbing the shared mapper and calling configure() on it is
|
||||||
|
* impossible in Jackson 3 — there are no mutators. The replacements are rebuild(),
|
||||||
|
* which forks a builder from an existing mapper, and reader()/writer() views for
|
||||||
|
* per-call tweaks. Neither disturbs the shared instance.
|
||||||
|
*/
|
||||||
|
public class Y04ImmutableMapperAndReaders {
|
||||||
|
|
||||||
|
public record UserProfile(String firstName, String lastName, String middleName) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper shared = JsonMapper.builder().build();
|
||||||
|
UserProfile user = new UserProfile("Ada", "Lovelace", null);
|
||||||
|
|
||||||
|
System.out.println("shared : " + shared.writeValueAsString(user));
|
||||||
|
|
||||||
|
// 1. rebuild(): fork the shared mapper's configuration and change one thing.
|
||||||
|
JsonMapper snakeCase = shared.rebuild()
|
||||||
|
.propertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
|
||||||
|
.changeDefaultPropertyInclusion(i -> i.withValueInclusion(JsonInclude.Include.NON_NULL))
|
||||||
|
.build();
|
||||||
|
System.out.println("rebuilt snake_case : " + snakeCase.writeValueAsString(user));
|
||||||
|
|
||||||
|
// 2. The shared mapper is untouched by the fork.
|
||||||
|
System.out.println("shared unchanged : " + shared.writeValueAsString(user));
|
||||||
|
|
||||||
|
// 3. writer()/reader() views for a single call, no new mapper needed.
|
||||||
|
System.out.println("writer view pretty : "
|
||||||
|
+ shared.writer().withDefaultPrettyPrinter().writeValueAsString(user)
|
||||||
|
.replace("\n", " ").replaceAll("\\s+", " "));
|
||||||
|
|
||||||
|
System.out.println("reader view strict : " + readStrict(shared));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readStrict(JsonMapper shared) {
|
||||||
|
String json = "{\"firstName\":\"Ada\",\"lastName\":\"Lovelace\",\"unexpected\":1}";
|
||||||
|
try {
|
||||||
|
shared.reader()
|
||||||
|
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.forType(UserProfile.class) // ObjectReader has no readValue(String, Class)
|
||||||
|
.readValue(json);
|
||||||
|
return "accepted";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "rejected (" + e.getClass().getSimpleName() + ") without touching the shared mapper";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import tools.jackson.databind.MapperFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — checking a claim rather than repeating it.
|
||||||
|
*
|
||||||
|
* The comparison post states that MapperFeature.AUTO_DETECT_CREATORS was removed and
|
||||||
|
* that, as a result, "any class relying on a single-argument constructor being detected
|
||||||
|
* without an annotation will quietly fail."
|
||||||
|
*
|
||||||
|
* Half right. The enum constant is genuinely gone. But the BEHAVIOUR it controlled is
|
||||||
|
* still there: a single-argument constructor is still detected as a delegating creator.
|
||||||
|
* The wrapper below deserialises with no @JsonCreator at all. Annotate anyway — it is
|
||||||
|
* explicit and free — but do not expect the upgrade to break these classes.
|
||||||
|
*/
|
||||||
|
public class Y05CreatorDetection {
|
||||||
|
|
||||||
|
/** No @JsonCreator anywhere. */
|
||||||
|
public static class ImplicitOrderId {
|
||||||
|
private final String value;
|
||||||
|
public ImplicitOrderId(String value) { this.value = value; }
|
||||||
|
@JsonValue public String value() { return value; }
|
||||||
|
@Override public String toString() { return "ImplicitOrderId[" + value + "]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The explicit form, which is what you should write. */
|
||||||
|
public static class ExplicitOrderId {
|
||||||
|
private final String value;
|
||||||
|
@JsonCreator public ExplicitOrderId(String value) { this.value = value; }
|
||||||
|
@JsonValue public String value() { return value; }
|
||||||
|
@Override public String toString() { return "ExplicitOrderId[" + value + "]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
boolean present = Arrays.stream(MapperFeature.values())
|
||||||
|
.anyMatch(f -> f.name().equals("AUTO_DETECT_CREATORS"));
|
||||||
|
System.out.println("MapperFeature.AUTO_DETECT_CREATORS exists : " + present);
|
||||||
|
System.out.println("Nearest surviving features : "
|
||||||
|
+ Arrays.stream(MapperFeature.values())
|
||||||
|
.filter(f -> f.name().contains("CREATOR") || f.name().contains("PARAMETER_NAMES"))
|
||||||
|
.map(Enum::name).toList());
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
System.out.println("implicit single-arg ctor : " + mapper.readValue("\"ord-1\"", ImplicitOrderId.class));
|
||||||
|
System.out.println("explicit @JsonCreator : " + mapper.readValue("\"ord-2\"", ExplicitOrderId.class));
|
||||||
|
System.out.println("round-trip via @JsonValue: " + mapper.writeValueAsString(new ImplicitOrderId("ord-3")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ankurm.jackson3.beyond;
|
||||||
|
|
||||||
|
import tools.jackson.core.json.JsonFactory;
|
||||||
|
import tools.jackson.core.util.JsonRecyclerPools;
|
||||||
|
import tools.jackson.core.util.RecyclerPool;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.Callable;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POSTS — the performance knob the comparison post names but never measures.
|
||||||
|
*
|
||||||
|
* Jackson 3 changed the default buffer RecyclerPool. The post says to restore the 2.x
|
||||||
|
* thread-local pool if you see a regression. Whether that helps depends entirely on your
|
||||||
|
* concurrency profile, so this measures it on the machine you are actually running on
|
||||||
|
* instead of asserting a winner.
|
||||||
|
*
|
||||||
|
* Indicative timings, not JMH. Run it a few times; the numbers move.
|
||||||
|
*/
|
||||||
|
public class Y06RecyclerPoolTuning {
|
||||||
|
|
||||||
|
public record Payload(long id, String name, List<String> tags, double amount) { }
|
||||||
|
|
||||||
|
private static final int ITERATIONS = 40_000;
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
System.out.println("default pool : "
|
||||||
|
+ JsonRecyclerPools.defaultPool().getClass().getSimpleName());
|
||||||
|
System.out.println("cores : " + Runtime.getRuntime().availableProcessors());
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
for (int threads : new int[] { 1, 8 }) {
|
||||||
|
System.out.println("--- " + threads + " thread(s), " + ITERATIONS + " round-trips each ---");
|
||||||
|
run(threads, "threadLocalPool (2.x default)", JsonRecyclerPools.threadLocalPool());
|
||||||
|
run(threads, "concurrentDeque (3.x default)", JsonRecyclerPools.newConcurrentDequePool());
|
||||||
|
run(threads, "nonRecyclingPool (no reuse) ", JsonRecyclerPools.nonRecyclingPool());
|
||||||
|
System.out.println();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void run(int threads, String label, RecyclerPool<?> pool) throws Exception {
|
||||||
|
@SuppressWarnings({ "unchecked", "rawtypes" })
|
||||||
|
JsonFactory factory = JsonFactory.builder().recyclerPool((RecyclerPool) pool).build();
|
||||||
|
JsonMapper mapper = JsonMapper.builder(factory).build();
|
||||||
|
|
||||||
|
Payload sample = new Payload(1L, "example", List.of("a", "b", "c"), 12.5);
|
||||||
|
for (int i = 0; i < 2_000; i++) { // warm up
|
||||||
|
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
ExecutorService pooled = Executors.newFixedThreadPool(threads);
|
||||||
|
long start = System.nanoTime();
|
||||||
|
var tasks = java.util.stream.IntStream.range(0, threads)
|
||||||
|
.<Callable<Void>>mapToObj(t -> () -> {
|
||||||
|
for (int i = 0; i < ITERATIONS; i++) {
|
||||||
|
mapper.readValue(mapper.writeValueAsString(sample), Payload.class);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).toList();
|
||||||
|
for (var future : pooled.invokeAll(tasks)) future.get();
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
pooled.shutdown();
|
||||||
|
|
||||||
|
System.out.printf(" %-34s %5d ms%n", label, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ankurm.jackson3.part0setup;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
|
||||||
|
* Section: "Your First Serialise/Deserialise Example"
|
||||||
|
*
|
||||||
|
* The simplest possible Jackson 3 round-trip: a POJO out to JSON and back.
|
||||||
|
*/
|
||||||
|
public class A01FirstRoundTrip {
|
||||||
|
|
||||||
|
/** A plain POJO with getters and setters — the classic Jackson shape. */
|
||||||
|
public static class ProductSummary {
|
||||||
|
private Long productId;
|
||||||
|
private String productName;
|
||||||
|
private double listPrice;
|
||||||
|
|
||||||
|
public ProductSummary() { } // needed for deserialisation
|
||||||
|
public ProductSummary(Long id, String name, double price) {
|
||||||
|
this.productId = id; this.productName = name; this.listPrice = price;
|
||||||
|
}
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public String getProductName() { return productName; }
|
||||||
|
public double getListPrice() { return listPrice; }
|
||||||
|
public void setProductId(Long v) { this.productId = v; }
|
||||||
|
public void setProductName(String v) { this.productName = v; }
|
||||||
|
public void setListPrice(double v) { this.listPrice = v; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// Jackson 3: JsonMapper.builder().build() replaces `new ObjectMapper()`.
|
||||||
|
// The result is IMMUTABLE — you cannot reconfigure it afterwards.
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
// Serialise: Java object -> JSON string
|
||||||
|
ProductSummary product = new ProductSummary(1L, "Mechanical Keyboard", 79.99);
|
||||||
|
String jsonOutput = mapper.writeValueAsString(product);
|
||||||
|
System.out.println(jsonOutput);
|
||||||
|
|
||||||
|
// Deserialise: JSON string -> Java object
|
||||||
|
ProductSummary restored = mapper.readValue(jsonOutput, ProductSummary.class);
|
||||||
|
System.out.println(restored.getProductName());
|
||||||
|
|
||||||
|
// Note: no `throws` clause anywhere in this method. In Jackson 3 the
|
||||||
|
// exception hierarchy is rooted at JacksonException extends RuntimeException,
|
||||||
|
// so serialisation failures are UNCHECKED. See beyond/Y01UncheckedExceptions.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.ankurm.jackson3.part0setup;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
|
||||||
|
* Section: "The ObjectMapper Lifecycle — The One Rule That Matters Most"
|
||||||
|
*
|
||||||
|
* Build the mapper ONCE at startup and share it. In Jackson 3 this is enforced
|
||||||
|
* by the API rather than by convention: the built mapper has no setters at all.
|
||||||
|
*/
|
||||||
|
public class A02SharedMapperConfiguration {
|
||||||
|
|
||||||
|
public record Invoice(Long invoiceId, String customerName, LocalDate issuedOn, String note) { }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Jackson 3 equivalent of the classic Spring @Bean ObjectMapper.
|
||||||
|
*
|
||||||
|
* Three of the four settings people habitually copy from Jackson 2 tutorials
|
||||||
|
* are unnecessary or wrong in Jackson 3 — the comments say which and why.
|
||||||
|
*/
|
||||||
|
static JsonMapper buildSharedMapper() {
|
||||||
|
return JsonMapper.builder()
|
||||||
|
// NOT NEEDED: FAIL_ON_UNKNOWN_PROPERTIES already defaults to false in
|
||||||
|
// Jackson 3. Listed here only because it is the single most-copied line
|
||||||
|
// from Jackson 2 configuration; deleting it changes nothing.
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
|
||||||
|
|
||||||
|
// NOT NEEDED: java.time support is built into jackson-databind 3.x and
|
||||||
|
// dates already serialise as ISO-8601 strings. There is no
|
||||||
|
// SerializationFeature.WRITE_DATES_AS_TIMESTAMPS in Jackson 3 — the flag
|
||||||
|
// moved to tools.jackson.databind.cfg.DateTimeFeature and is off by
|
||||||
|
// default. See beyond/Y03DateTimeDefaults for proof.
|
||||||
|
|
||||||
|
// Skip null fields. Jackson 2's setSerializationInclusion(...) and the
|
||||||
|
// builder's serializationInclusion(...) do NOT exist in Jackson 3.
|
||||||
|
// The real API is changeDefaultPropertyInclusion.
|
||||||
|
.changeDefaultPropertyInclusion(
|
||||||
|
incl -> incl.withValueInclusion(JsonInclude.Include.NON_NULL))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = buildSharedMapper();
|
||||||
|
|
||||||
|
Invoice invoice = new Invoice(500L, "Alice", LocalDate.of(2026, 4, 9), null);
|
||||||
|
System.out.println("configured : " + mapper.writeValueAsString(invoice));
|
||||||
|
|
||||||
|
JsonMapper plain = JsonMapper.builder().build();
|
||||||
|
System.out.println("defaults : " + plain.writeValueAsString(invoice));
|
||||||
|
|
||||||
|
// Proof that the mapper is immutable: ObjectMapper in Jackson 3 exposes no
|
||||||
|
// set*() mutators at all, so there is no way to reconfigure a shared instance.
|
||||||
|
long setterCount = java.util.Arrays.stream(
|
||||||
|
tools.jackson.databind.ObjectMapper.class.getMethods())
|
||||||
|
.filter(m -> m.getName().startsWith("set"))
|
||||||
|
.count();
|
||||||
|
System.out.println("ObjectMapper set*() methods in Jackson 3: " + setterCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.ankurm.jackson3.part0setup;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser;
|
||||||
|
import tools.jackson.core.JsonToken;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson 101 — https://ankurm.com/jackson-java-tutorial/
|
||||||
|
* Section: "Jackson's Three Processing Models"
|
||||||
|
*
|
||||||
|
* The same payload read three ways, so the trade-off is concrete rather than a table.
|
||||||
|
*/
|
||||||
|
public class A03ThreeProcessingModels {
|
||||||
|
|
||||||
|
public record Order(Long orderId, String status) { }
|
||||||
|
|
||||||
|
private static final String JSON = "{\"orderId\":1001,\"status\":\"SHIPPED\"}";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
// 1. DATA BINDING — the right answer roughly 95% of the time.
|
||||||
|
Order bound = mapper.readValue(JSON, Order.class);
|
||||||
|
System.out.println("1. data binding : " + bound);
|
||||||
|
|
||||||
|
// 2. TREE MODEL — schema not known at compile time; navigate a JsonNode.
|
||||||
|
JsonNode tree = mapper.readTree(JSON);
|
||||||
|
System.out.println("2. tree model : orderId=" + tree.path("orderId").asInt()
|
||||||
|
+ " status=" + tree.path("status").asString());
|
||||||
|
|
||||||
|
// 3. STREAMING — token by token, constant memory, no document ever built.
|
||||||
|
StringBuilder streamed = new StringBuilder();
|
||||||
|
try (JsonParser parser = mapper.createParser(JSON)) {
|
||||||
|
while (parser.nextToken() != null) {
|
||||||
|
if (parser.currentToken() == JsonToken.PROPERTY_NAME) {
|
||||||
|
String field = parser.currentName();
|
||||||
|
parser.nextToken(); // advance to the value
|
||||||
|
streamed.append(field).append('=').append(parser.getString()).append(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("3. streaming : " + streamed.toString().trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.ankurm.jackson3.part1objectmapper;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
|
||||||
|
* Section: "Serialising Java Objects to JSON"
|
||||||
|
*
|
||||||
|
* Every write target: String, File, and pretty-printed String.
|
||||||
|
*/
|
||||||
|
public class B01WriteJson {
|
||||||
|
|
||||||
|
public record Article(Long articleId, String title, List<String> tags) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
Article article = new Article(1L, "Jackson Deep Dive", List.of("java", "json"));
|
||||||
|
|
||||||
|
// 1. Write to a String
|
||||||
|
String jsonOutput = mapper.writeValueAsString(article);
|
||||||
|
System.out.println(jsonOutput);
|
||||||
|
|
||||||
|
// 2. Write to a File
|
||||||
|
File target = File.createTempFile("article", ".json");
|
||||||
|
mapper.writeValue(target, article);
|
||||||
|
System.out.println("file : " + Files.readString(target.toPath()));
|
||||||
|
|
||||||
|
// 3. Pretty-printed output
|
||||||
|
String pretty = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(article);
|
||||||
|
System.out.println("pretty :");
|
||||||
|
System.out.println(pretty);
|
||||||
|
|
||||||
|
target.deleteOnExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ankurm.jackson3.part1objectmapper;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
|
||||||
|
* Section: "Deserialising JSON to Java Objects"
|
||||||
|
*
|
||||||
|
* Reading from a String, a File and an InputStream.
|
||||||
|
*
|
||||||
|
* The post also shows readValue(new URL(...), ...). That overload is deliberately
|
||||||
|
* NOT reproduced here: it performs a live network call, which would make this
|
||||||
|
* example non-reproducible. The InputStream form below is what a real HTTP client
|
||||||
|
* hands you anyway.
|
||||||
|
*/
|
||||||
|
public class B02ReadJson {
|
||||||
|
|
||||||
|
public record Article(Long articleId, String title, List<String> tags) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
String json = "{\"articleId\":1,\"title\":\"Jackson Deep Dive\",\"tags\":[\"java\",\"json\"]}";
|
||||||
|
|
||||||
|
// 1. Read from a String
|
||||||
|
Article fromString = mapper.readValue(json, Article.class);
|
||||||
|
System.out.println("from String : " + fromString.title());
|
||||||
|
|
||||||
|
// 2. Read from a File
|
||||||
|
File file = File.createTempFile("article", ".json");
|
||||||
|
Files.writeString(file.toPath(), json);
|
||||||
|
Article fromFile = mapper.readValue(file, Article.class);
|
||||||
|
System.out.println("from File : " + fromFile.articleId());
|
||||||
|
|
||||||
|
// 3. Read from an InputStream
|
||||||
|
try (var in = new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))) {
|
||||||
|
Article fromStream = mapper.readValue(in, Article.class);
|
||||||
|
System.out.println("from Stream : " + fromStream.tags());
|
||||||
|
}
|
||||||
|
|
||||||
|
file.deleteOnExit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ankurm.jackson3.part1objectmapper;
|
||||||
|
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson ObjectMapper Guide — https://ankurm.com/jackson-objectmapper-guide/
|
||||||
|
* Section: "Working with Collections and Generic Types"
|
||||||
|
*
|
||||||
|
* Why TypeReference is required, and what actually happens without it.
|
||||||
|
*
|
||||||
|
* Note the import: TypeReference lives in tools.jackson.core.type in Jackson 3.
|
||||||
|
*/
|
||||||
|
public class B03GenericCollections {
|
||||||
|
|
||||||
|
public record Article(Long articleId, String title) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
String jsonArray = "[{\"articleId\":1,\"title\":\"First\"},"
|
||||||
|
+ "{\"articleId\":2,\"title\":\"Second\"}]";
|
||||||
|
|
||||||
|
// CORRECT: TypeReference captures List<Article> through an anonymous subclass,
|
||||||
|
// so the parameterised type survives erasure and reaches Jackson at runtime.
|
||||||
|
List<Article> articles = mapper.readValue(jsonArray, new TypeReference<List<Article>>() { });
|
||||||
|
System.out.println("size : " + articles.size());
|
||||||
|
System.out.println("element class : " + articles.get(0).getClass().getSimpleName());
|
||||||
|
System.out.println("first title : " + articles.get(0).title());
|
||||||
|
|
||||||
|
// WRONG: List.class erases the element type. This COMPILES and does not throw
|
||||||
|
// here — the failure is deferred to the first time you treat an element as an
|
||||||
|
// Article, which is what makes it such an unpleasant bug.
|
||||||
|
@SuppressWarnings("rawtypes")
|
||||||
|
List raw = mapper.readValue(jsonArray, List.class);
|
||||||
|
System.out.println("raw element : " + raw.get(0).getClass().getSimpleName()
|
||||||
|
+ " <- not Article");
|
||||||
|
try {
|
||||||
|
Article boom = (Article) raw.get(0);
|
||||||
|
System.out.println("unreachable: " + boom);
|
||||||
|
} catch (ClassCastException e) {
|
||||||
|
System.out.println("cast fails : ClassCastException, as expected");
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Map value type needs the same treatment.
|
||||||
|
String jsonObject = "{\"a\":{\"articleId\":9,\"title\":\"Nine\"}}";
|
||||||
|
Map<String, Article> byKey =
|
||||||
|
mapper.readValue(jsonObject, new TypeReference<Map<String, Article>>() { });
|
||||||
|
System.out.println("map value : " + byKey.get("a").title());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ankurm.jackson3.part1objectmapper;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — no blog section covers this, but it bites on first run.
|
||||||
|
*
|
||||||
|
* A record serialises in declaration order. A getter-based POJO serialises in
|
||||||
|
* ALPHABETICAL order. If you are diffing Jackson output against a fixture, this
|
||||||
|
* is usually the reason the diff is not empty.
|
||||||
|
*/
|
||||||
|
public class B04PropertyOrdering {
|
||||||
|
|
||||||
|
/** Getter-based POJO: output is alphabetical, NOT declaration order. */
|
||||||
|
public static class ProductPojo {
|
||||||
|
private Long productId;
|
||||||
|
private String productName;
|
||||||
|
private double listPrice;
|
||||||
|
public ProductPojo(Long i, String n, double p) { productId = i; productName = n; listPrice = p; }
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public String getProductName() { return productName; }
|
||||||
|
public double getListPrice() { return listPrice; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Record: output follows the component declaration order. */
|
||||||
|
public record ProductRecord(Long productId, String productName, double listPrice) { }
|
||||||
|
|
||||||
|
/** Explicit ordering wins over both defaults. */
|
||||||
|
@JsonPropertyOrder({ "productId", "productName", "listPrice" })
|
||||||
|
public static class ProductOrdered {
|
||||||
|
private final Long productId;
|
||||||
|
private final String productName;
|
||||||
|
private final double listPrice;
|
||||||
|
public ProductOrdered(Long i, String n, double p) { productId = i; productName = n; listPrice = p; }
|
||||||
|
public Long getProductId() { return productId; }
|
||||||
|
public String getProductName() { return productName; }
|
||||||
|
public double getListPrice() { return listPrice; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
System.out.println("POJO : " + mapper.writeValueAsString(
|
||||||
|
new ProductPojo(1L, "Mechanical Keyboard", 79.99)));
|
||||||
|
System.out.println("record : " + mapper.writeValueAsString(
|
||||||
|
new ProductRecord(1L, "Mechanical Keyboard", 79.99)));
|
||||||
|
System.out.println("ordered : " + mapper.writeValueAsString(
|
||||||
|
new ProductOrdered(1L, "Mechanical Keyboard", 79.99)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.ankurm.jackson3.part2modernjava;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson with Records, Optionals, Sealed Classes
|
||||||
|
* https://ankurm.com/jackson-java-records-optionals/
|
||||||
|
* Section: "Jackson with Java Records"
|
||||||
|
*
|
||||||
|
* Records need no module, no annotation and no -parameters compiler flag in
|
||||||
|
* Jackson 3. Check the pom: there is no jackson-module-parameter-names dependency
|
||||||
|
* and no <compilerArgs>.
|
||||||
|
*/
|
||||||
|
public class C01RecordRoundTrip {
|
||||||
|
|
||||||
|
/** A concise, immutable data transfer object. */
|
||||||
|
public record ProductRecord(Long productId, String productName, double unitPrice) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
// Serialise: Record -> JSON. Accessor methods replace getters.
|
||||||
|
ProductRecord product = new ProductRecord(101L, "Wireless Keyboard", 49.99);
|
||||||
|
String jsonOutput = mapper.writeValueAsString(product);
|
||||||
|
System.out.println(jsonOutput);
|
||||||
|
|
||||||
|
// Deserialise: JSON -> Record. The canonical constructor is located through
|
||||||
|
// the RecordComponent reflection API (Java 16+), not through parameter names.
|
||||||
|
String json = "{\"productId\":101,\"productName\":\"Wireless Keyboard\",\"unitPrice\":49.99}";
|
||||||
|
ProductRecord restored = mapper.readValue(json, ProductRecord.class);
|
||||||
|
System.out.println(restored.productName());
|
||||||
|
|
||||||
|
// Records also give you equals() for free, so a round-trip is assertable.
|
||||||
|
System.out.println("round-trip equal: " + product.equals(restored));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.ankurm.jackson3.part2modernjava;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson with Records, Optionals, Sealed Classes
|
||||||
|
* https://ankurm.com/jackson-java-records-optionals/
|
||||||
|
* Section: "Jackson with Optional<T>"
|
||||||
|
*
|
||||||
|
* Jackson 3 handles Optional natively. There is no jackson-datatype-jdk8
|
||||||
|
* dependency in the pom and no registerModule(new Jdk8Module()) call — those are
|
||||||
|
* Jackson 2 requirements, and the Jdk8Module class does not exist under
|
||||||
|
* tools.jackson at all.
|
||||||
|
*/
|
||||||
|
public class C02OptionalFields {
|
||||||
|
|
||||||
|
public record CustomerProfile(String customerName, Optional<String> middleName) { }
|
||||||
|
|
||||||
|
/** NON_ABSENT is the inclusion value that understands Optional.empty(). */
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_ABSENT)
|
||||||
|
public record CompactProfile(String customerName, Optional<String> middleName) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
// Present value: the Optional is unwrapped, not wrapped in {"present":true}.
|
||||||
|
System.out.println("present : "
|
||||||
|
+ mapper.writeValueAsString(new CustomerProfile("Alice", Optional.of("Marie"))));
|
||||||
|
|
||||||
|
// Empty Optional: serialises as null by default.
|
||||||
|
System.out.println("empty : "
|
||||||
|
+ mapper.writeValueAsString(new CustomerProfile("Bob", Optional.empty())));
|
||||||
|
|
||||||
|
// NON_ABSENT omits the property entirely instead of writing null.
|
||||||
|
System.out.println("absent : "
|
||||||
|
+ mapper.writeValueAsString(new CompactProfile("Bob", Optional.empty())));
|
||||||
|
|
||||||
|
// Deserialise back.
|
||||||
|
String json = "{\"customerName\":\"Alice\",\"middleName\":\"Marie\"}";
|
||||||
|
CustomerProfile restored = mapper.readValue(json, CustomerProfile.class);
|
||||||
|
System.out.println("isPresent: " + restored.middleName().isPresent());
|
||||||
|
|
||||||
|
// A missing property deserialises to Optional.empty(), never to null.
|
||||||
|
CustomerProfile missing = mapper.readValue("{\"customerName\":\"Carol\"}", CustomerProfile.class);
|
||||||
|
System.out.println("missing -> " + missing.middleName() + " (null? "
|
||||||
|
+ (missing.middleName() == null) + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.jackson3.part2modernjava;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson with Records, Optionals, Sealed Classes
|
||||||
|
* https://ankurm.com/jackson-java-records-optionals/
|
||||||
|
* Section: "Jackson with Sealed Classes (Java 17+)"
|
||||||
|
*
|
||||||
|
* The explicit-registry form: @JsonTypeInfo plus a hand-maintained @JsonSubTypes.
|
||||||
|
* Compare with C04SealedAutoDiscovery, which drops the registry entirely.
|
||||||
|
*/
|
||||||
|
public class C03SealedWithSubTypes {
|
||||||
|
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "shapeType")
|
||||||
|
@JsonSubTypes({
|
||||||
|
@JsonSubTypes.Type(value = Circle.class, name = "circle"),
|
||||||
|
@JsonSubTypes.Type(value = Rectangle.class, name = "rectangle")
|
||||||
|
})
|
||||||
|
public sealed interface Shape permits Circle, Rectangle { }
|
||||||
|
|
||||||
|
public record Circle(double radius) implements Shape { }
|
||||||
|
public record Rectangle(double width, double height) implements Shape { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
String json = "["
|
||||||
|
+ "{\"shapeType\":\"circle\",\"radius\":5.0},"
|
||||||
|
+ "{\"shapeType\":\"rectangle\",\"width\":10.0,\"height\":4.0}"
|
||||||
|
+ "]";
|
||||||
|
|
||||||
|
List<Shape> shapes = mapper.readValue(json, new TypeReference<List<Shape>>() { });
|
||||||
|
|
||||||
|
// Java 21 pattern matching for switch — exhaustive because Shape is sealed,
|
||||||
|
// so no default branch is needed and a new permitted type is a compile error.
|
||||||
|
for (Shape shape : shapes) {
|
||||||
|
String description = switch (shape) {
|
||||||
|
case Circle c -> "Circle with radius: " + c.radius();
|
||||||
|
case Rectangle r -> "Rectangle " + r.width() + " x " + r.height();
|
||||||
|
};
|
||||||
|
System.out.println(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAREFUL: writeValueAsString(List<Shape>) loses the discriminator, because
|
||||||
|
// the runtime type of the list carries no element type for Jackson to read.
|
||||||
|
// The result does not round-trip. See part5polymorphic/F02 for the full story.
|
||||||
|
System.out.println("lossy : " + mapper.writeValueAsString(shapes));
|
||||||
|
System.out.println("correct : " + mapper.writerFor(new TypeReference<List<Shape>>() { })
|
||||||
|
.writeValueAsString(shapes));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ankurm.jackson3.part2modernjava;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — the Jackson-3-only shortcut.
|
||||||
|
*
|
||||||
|
* Jackson 3 introspects the `permits` clause of a sealed type, so @JsonSubTypes
|
||||||
|
* can be dropped as long as each permitted type carries @JsonTypeName. That
|
||||||
|
* removes the parallel registry which, in Jackson 2, silently drifts out of sync
|
||||||
|
* with `permits` whenever someone adds a subtype.
|
||||||
|
*
|
||||||
|
* Note there is NO @JsonSubTypes anywhere in this file.
|
||||||
|
*/
|
||||||
|
public class C04SealedAutoDiscovery {
|
||||||
|
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "shapeType")
|
||||||
|
public sealed interface Shape permits Circle, Rectangle, Triangle { }
|
||||||
|
|
||||||
|
@JsonTypeName("circle") public record Circle(double radius) implements Shape { }
|
||||||
|
@JsonTypeName("rectangle") public record Rectangle(double width, double height) implements Shape { }
|
||||||
|
// Added later. In Jackson 2 this line alone would break deserialisation until
|
||||||
|
// someone remembered to also add it to @JsonSubTypes. Here it just works.
|
||||||
|
@JsonTypeName("triangle") public record Triangle(double base, double height) implements Shape { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
for (Shape original : new Shape[] {
|
||||||
|
new Circle(5.0), new Rectangle(10.0, 4.0), new Triangle(3.0, 6.0) }) {
|
||||||
|
|
||||||
|
String json = mapper.writeValueAsString(original);
|
||||||
|
Shape restored = mapper.readValue(json, Shape.class);
|
||||||
|
System.out.printf("%-24s -> %-52s -> %s%n",
|
||||||
|
original.getClass().getSimpleName(), json, restored);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ankurm.jackson3.part3annotations;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
|
||||||
|
* Sections: "@JsonProperty" and "@JsonIgnore"
|
||||||
|
*
|
||||||
|
* Every @Json* annotation is imported from com.fasterxml.jackson.annotation, even
|
||||||
|
* on Jackson 3. jackson-annotations deliberately keeps the old group ID and package
|
||||||
|
* so one copy can be shared by Jackson 2 and Jackson 3 code on the same classpath.
|
||||||
|
*/
|
||||||
|
public class D01RenameAndIgnore {
|
||||||
|
|
||||||
|
public record OrderSummary(
|
||||||
|
@JsonProperty("order_id") Long orderId,
|
||||||
|
@JsonProperty("customer_name") String customerName) { }
|
||||||
|
|
||||||
|
public record UserAccount(
|
||||||
|
String username,
|
||||||
|
@JsonIgnore String passwordHash) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
System.out.println("rename : "
|
||||||
|
+ mapper.writeValueAsString(new OrderSummary(1001L, "Alice")));
|
||||||
|
|
||||||
|
// Deserialisation honours the renamed key in both directions.
|
||||||
|
OrderSummary back = mapper.readValue(
|
||||||
|
"{\"order_id\":1001,\"customer_name\":\"Alice\"}", OrderSummary.class);
|
||||||
|
System.out.println("read back : " + back);
|
||||||
|
|
||||||
|
System.out.println("ignore : "
|
||||||
|
+ mapper.writeValueAsString(new UserAccount("alice", "$2a$10$secret")));
|
||||||
|
|
||||||
|
// @JsonIgnore is bidirectional: the field is not read from JSON either.
|
||||||
|
UserAccount ignored = mapper.readValue(
|
||||||
|
"{\"username\":\"alice\",\"passwordHash\":\"injected\"}", UserAccount.class);
|
||||||
|
System.out.println("read back : passwordHash=" + ignored.passwordHash());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.jackson3.part3annotations;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
|
||||||
|
* Sections: "@JsonInclude" and "@JsonFormat"
|
||||||
|
*
|
||||||
|
* One correction to the post: it says "Without @JsonFormat, Jackson writes LocalDate
|
||||||
|
* as a numeric array by default." That was true in Jackson 2. In Jackson 3, java.time
|
||||||
|
* support is built in and ISO-8601 is the default — the annotation is only needed for
|
||||||
|
* a NON-standard pattern. The `defaultDate` field below proves it.
|
||||||
|
*/
|
||||||
|
public class D02InclusionAndFormat {
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ProductDetails(String productName, String productDescription, Double discountRate) { }
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||||
|
public record CompactProduct(String productName, String notes, List<String> tags) { }
|
||||||
|
|
||||||
|
public record InvoiceRecord(
|
||||||
|
Long invoiceId,
|
||||||
|
LocalDate defaultDate, // no annotation
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "dd/MM/yyyy")
|
||||||
|
LocalDate ukStyleDate, // custom pattern
|
||||||
|
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||||
|
double totalAmount) { } // number as string
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
System.out.println("NON_NULL : "
|
||||||
|
+ mapper.writeValueAsString(new ProductDetails("Keyboard", null, null)));
|
||||||
|
System.out.println("NON_EMPTY : "
|
||||||
|
+ mapper.writeValueAsString(new CompactProduct("Keyboard", "", List.of())));
|
||||||
|
|
||||||
|
System.out.println("formats : " + mapper.writeValueAsString(new InvoiceRecord(
|
||||||
|
500L, LocalDate.of(2026, 4, 9), LocalDate.of(2026, 4, 9), 199.99)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.jackson3.part3annotations;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
|
||||||
|
* Sections: "@JsonAlias" and "@JsonIgnoreProperties"
|
||||||
|
*
|
||||||
|
* Correction to the post: it frames @JsonIgnoreProperties(ignoreUnknown = true) as the
|
||||||
|
* per-class alternative to configuring FAIL_ON_UNKNOWN_PROPERTIES globally. In Jackson 3
|
||||||
|
* that feature is already DISABLED by default, so unknown fields are tolerated with no
|
||||||
|
* annotation at all. The annotation now matters mainly when you have deliberately turned
|
||||||
|
* strictness back ON — which is what the `strict` mapper below does.
|
||||||
|
*/
|
||||||
|
public class D03AliasAndUnknownFields {
|
||||||
|
|
||||||
|
public record SearchQuery(
|
||||||
|
@JsonAlias({ "q", "query", "search_term" }) String searchKeyword) { }
|
||||||
|
|
||||||
|
public record LenientResponse(String status, String message) { }
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record OptedOutResponse(String status, String message) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
for (String json : new String[] {
|
||||||
|
"{\"q\":\"jackson\"}", "{\"query\":\"jackson\"}", "{\"search_term\":\"jackson\"}" }) {
|
||||||
|
System.out.println("alias " + String.format("%-24s", json)
|
||||||
|
+ " -> " + mapper.readValue(json, SearchQuery.class).searchKeyword());
|
||||||
|
}
|
||||||
|
|
||||||
|
String extra = "{\"status\":\"OK\",\"message\":\"done\",\"undocumentedField\":42}";
|
||||||
|
|
||||||
|
// Default Jackson 3 mapper: unknown fields are already ignored.
|
||||||
|
System.out.println("default mapper : " + mapper.readValue(extra, LenientResponse.class));
|
||||||
|
|
||||||
|
// A mapper with strictness deliberately re-enabled.
|
||||||
|
JsonMapper strict = JsonMapper.builder()
|
||||||
|
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
strict.readValue(extra, LenientResponse.class);
|
||||||
|
System.out.println("strict mapper : unreachable");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("strict mapper : " + e.getClass().getSimpleName() + " (as expected)");
|
||||||
|
}
|
||||||
|
// ...but the annotation opts this one class back out of the strictness.
|
||||||
|
System.out.println("strict + anno : " + strict.readValue(extra, OptedOutResponse.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.ankurm.jackson3.part3annotations;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnyGetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonCreator;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonUnwrapped;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Annotations Cheat Sheet — https://ankurm.com/jackson-annotations-guide/
|
||||||
|
* Sections: "@JsonCreator", plus @JsonUnwrapped which the post's table lists but
|
||||||
|
* never demonstrates. @JsonAnyGetter/@JsonAnySetter are beyond the post entirely.
|
||||||
|
*/
|
||||||
|
public class D04CreatorsAndUnwrapping {
|
||||||
|
|
||||||
|
/** Immutable class with no setters: the creator tells Jackson how to build it. */
|
||||||
|
public static class ImmutablePoint {
|
||||||
|
private final double xCoordinate;
|
||||||
|
private final double yCoordinate;
|
||||||
|
|
||||||
|
@JsonCreator
|
||||||
|
public ImmutablePoint(@JsonProperty("x") double xCoordinate,
|
||||||
|
@JsonProperty("y") double yCoordinate) {
|
||||||
|
this.xCoordinate = xCoordinate;
|
||||||
|
this.yCoordinate = yCoordinate;
|
||||||
|
}
|
||||||
|
@JsonProperty("x") public double getXCoordinate() { return xCoordinate; }
|
||||||
|
@JsonProperty("y") public double getYCoordinate() { return yCoordinate; }
|
||||||
|
@Override public String toString() {
|
||||||
|
return "ImmutablePoint(x=" + xCoordinate + ", y=" + yCoordinate + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Address(String street, String city) { }
|
||||||
|
|
||||||
|
/** @JsonUnwrapped flattens the nested object into the parent's JSON object. */
|
||||||
|
public static class Customer {
|
||||||
|
public String customerName;
|
||||||
|
@JsonUnwrapped public Address address;
|
||||||
|
public Customer() { }
|
||||||
|
public Customer(String n, Address a) { customerName = n; address = a; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any unmapped properties land in a Map instead of being dropped. */
|
||||||
|
public static class FlexiblePayload {
|
||||||
|
public String knownField;
|
||||||
|
private final Map<String, Object> extras = new LinkedHashMap<>();
|
||||||
|
@JsonAnyGetter public Map<String, Object> extras() { return extras; }
|
||||||
|
@JsonAnySetter public void put(String k, Object v) { extras.put(k, v); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
ImmutablePoint point = mapper.readValue("{\"x\":3.5,\"y\":7.2}", ImmutablePoint.class);
|
||||||
|
System.out.println("creator : " + point);
|
||||||
|
System.out.println("round-trip: " + mapper.writeValueAsString(point));
|
||||||
|
|
||||||
|
System.out.println("unwrapped : " + mapper.writeValueAsString(
|
||||||
|
new Customer("Alice", new Address("123 Main St", "Springfield"))));
|
||||||
|
|
||||||
|
FlexiblePayload flexible = mapper.readValue(
|
||||||
|
"{\"knownField\":\"a\",\"surprise\":1,\"another\":[true,false]}", FlexiblePayload.class);
|
||||||
|
System.out.println("any-setter: " + flexible.extras());
|
||||||
|
System.out.println("any-getter: " + mapper.writeValueAsString(flexible));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonGenerator;
|
||||||
|
import tools.jackson.databind.SerializationContext;
|
||||||
|
import tools.jackson.databind.ser.std.StdSerializer;
|
||||||
|
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
|
||||||
|
* Section: "Writing a Custom Serialiser"
|
||||||
|
*
|
||||||
|
* Three Jackson 3 differences from the code in the post:
|
||||||
|
* 1. Package is tools.jackson.databind.ser.std, not com.fasterxml.jackson...
|
||||||
|
* 2. The third parameter is SerializationContext, not SerializerProvider.
|
||||||
|
* 3. There is no `throws IOException` — JacksonException is unchecked in Jackson 3.
|
||||||
|
*
|
||||||
|
* (StdSerializer still exists under its old name; only JsonSerializer was renamed,
|
||||||
|
* to ValueSerializer. StdSerializer extends ValueSerializer.)
|
||||||
|
*/
|
||||||
|
public class E01MoneyValueSerializer extends StdSerializer<Money> {
|
||||||
|
|
||||||
|
public E01MoneyValueSerializer() {
|
||||||
|
super(Money.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void serialize(Money moneyValue, JsonGenerator jsonGenerator, SerializationContext ctxt) {
|
||||||
|
jsonGenerator.writeStartObject();
|
||||||
|
// Write the amount rounded to 2 decimal places
|
||||||
|
jsonGenerator.writeNumberProperty("amount",
|
||||||
|
moneyValue.amount().setScale(2, RoundingMode.HALF_UP));
|
||||||
|
// Write the ISO currency code in uppercase
|
||||||
|
jsonGenerator.writeStringProperty("currency",
|
||||||
|
moneyValue.currencyCode().toUpperCase());
|
||||||
|
jsonGenerator.writeEndObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser;
|
||||||
|
import tools.jackson.databind.DeserializationContext;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.deser.std.StdDeserializer;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
|
||||||
|
* Section: "Writing a Custom Deserialiser"
|
||||||
|
*
|
||||||
|
* Jackson 3 differences from the post's code:
|
||||||
|
* 1. jsonParser.getCodec().readTree(jsonParser) is gone. Use ctxt.readTree(parser).
|
||||||
|
* 2. No `throws IOException` — JacksonException is unchecked.
|
||||||
|
* 3. path() rather than get(), so a missing field yields a MissingNode instead of
|
||||||
|
* a NullPointerException. The post's version NPEs on {"currency":"USD"}.
|
||||||
|
* 4. A bare decimalValue() on a MissingNode THROWS in Jackson 3 (it returned
|
||||||
|
* BigDecimal.ZERO in Jackson 2). Use the defaulting overload.
|
||||||
|
*/
|
||||||
|
public class E02MoneyValueDeserializer extends StdDeserializer<Money> {
|
||||||
|
|
||||||
|
public E02MoneyValueDeserializer() {
|
||||||
|
super(Money.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Money deserialize(JsonParser jsonParser, DeserializationContext ctxt) {
|
||||||
|
JsonNode rootNode = ctxt.readTree(jsonParser);
|
||||||
|
BigDecimal amount = rootNode.path("amount").decimalValue(BigDecimal.ZERO);
|
||||||
|
String currency = rootNode.path("currency").asString("GBP"); // default when absent
|
||||||
|
return new Money(amount, currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import tools.jackson.databind.DeserializationFeature;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
import tools.jackson.databind.module.SimpleModule;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
|
||||||
|
* Section: "Registering the Serialiser and Deserialiser via SimpleModule"
|
||||||
|
*
|
||||||
|
* Jackson 3 differences:
|
||||||
|
* 1. SimpleModule moved to tools.jackson.databind.module.
|
||||||
|
* 2. The Version-taking constructor from the post is gone; pass just a name.
|
||||||
|
* 3. The module is attached with builder.addModule(...), not mapper.registerModule(...),
|
||||||
|
* because a built mapper is immutable.
|
||||||
|
*/
|
||||||
|
public class E03SimpleModuleRegistration {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SimpleModule moneyModule = new SimpleModule("MoneyModule");
|
||||||
|
moneyModule.addSerializer(Money.class, new E01MoneyValueSerializer());
|
||||||
|
moneyModule.addDeserializer(Money.class, new E02MoneyValueDeserializer());
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder()
|
||||||
|
.addModule(moneyModule)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Serialise: note the rounding and the upper-casing done by the serialiser.
|
||||||
|
Money price = new Money(new BigDecimal("19.999"), "usd");
|
||||||
|
System.out.println("serialised : " + mapper.writeValueAsString(price));
|
||||||
|
|
||||||
|
// Deserialise. NOTE: the scale is NOT preserved by default — Jackson parses
|
||||||
|
// 20.00 as a double first, so you get 20.0 and not 20.00. The blog post claims
|
||||||
|
// 20.00; that only holds if you turn on USE_BIG_DECIMAL_FOR_FLOATS, below.
|
||||||
|
Money restored = mapper.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class);
|
||||||
|
System.out.println("amount : " + restored.amount() + " (scale lost)");
|
||||||
|
|
||||||
|
JsonMapper exact = JsonMapper.builder()
|
||||||
|
.addModule(moneyModule)
|
||||||
|
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
|
||||||
|
.build();
|
||||||
|
System.out.println("amount exact : "
|
||||||
|
+ exact.readValue("{\"amount\":20.00,\"currency\":\"USD\"}", Money.class).amount()
|
||||||
|
+ " (scale preserved)");
|
||||||
|
|
||||||
|
// The path()-based deserialiser tolerates a missing field; the post's get()
|
||||||
|
// version would throw NullPointerException here.
|
||||||
|
Money partial = mapper.readValue("{\"currency\":\"EUR\"}", Money.class);
|
||||||
|
System.out.println("missing field: " + partial);
|
||||||
|
|
||||||
|
// Without the module the record would serialise structurally instead.
|
||||||
|
JsonMapper plain = JsonMapper.builder().build();
|
||||||
|
System.out.println("no module : " + plain.writeValueAsString(price));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Custom Serialisers and Mix-ins — https://ankurm.com/jackson-custom-serializer-mixin/
|
||||||
|
* Section: "Mix-in Annotations — Annotating Third-Party Classes"
|
||||||
|
*
|
||||||
|
* Jackson 3 difference: mixins are registered on the BUILDER (addMixIn), because
|
||||||
|
* mapper.addMixIn(...) does not exist on an immutable mapper.
|
||||||
|
*/
|
||||||
|
public class E04MixinAnnotations {
|
||||||
|
|
||||||
|
/** Stand-in for a third-party class whose source you cannot modify. */
|
||||||
|
public static class Address {
|
||||||
|
public String street;
|
||||||
|
public String city;
|
||||||
|
public String postalCode;
|
||||||
|
public String internalTrackingCode; // must never reach the wire
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mix-in: carries the annotations Jackson should apply to Address. */
|
||||||
|
public abstract static class AddressMixin {
|
||||||
|
@JsonIgnore public String internalTrackingCode; // suppress
|
||||||
|
@JsonProperty("zip") public String postalCode; // rename
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder()
|
||||||
|
.addMixIn(Address.class, AddressMixin.class)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Address address = new Address();
|
||||||
|
address.street = "123 Main St";
|
||||||
|
address.city = "Springfield";
|
||||||
|
address.postalCode = "12345";
|
||||||
|
address.internalTrackingCode = "INTERNAL-X99";
|
||||||
|
|
||||||
|
System.out.println("with mixin : " + mapper.writeValueAsString(address));
|
||||||
|
|
||||||
|
// The target class is untouched — a mapper without the mixin still sees
|
||||||
|
// every field under its original name.
|
||||||
|
System.out.println("without mixin: "
|
||||||
|
+ JsonMapper.builder().build().writeValueAsString(address));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonGenerator;
|
||||||
|
import tools.jackson.databind.SerializationContext;
|
||||||
|
import tools.jackson.databind.ValueSerializer;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
import tools.jackson.databind.module.SimpleModule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — the rename that breaks every custom handler on upgrade.
|
||||||
|
*
|
||||||
|
* Jackson 2's JsonSerializer<T>/JsonDeserializer<T> are gone. The Jackson 3 names
|
||||||
|
* are ValueSerializer<T>/ValueDeserializer<T>. Extending ValueSerializer directly
|
||||||
|
* (rather than StdSerializer) is the leanest form and shows the rename plainly.
|
||||||
|
*/
|
||||||
|
public class E05ValueSerializerDirect {
|
||||||
|
|
||||||
|
public record UserId(String value) { }
|
||||||
|
|
||||||
|
/** Renders the wrapper as a bare JSON string rather than {"value":"..."}. */
|
||||||
|
static class UserIdSerializer extends ValueSerializer<UserId> {
|
||||||
|
@Override
|
||||||
|
public void serialize(UserId id, JsonGenerator gen, SerializationContext ctxt) {
|
||||||
|
gen.writeString(id.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record Ticket(UserId assignee, String title) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SimpleModule module = new SimpleModule("UserIdModule");
|
||||||
|
module.addSerializer(UserId.class, new UserIdSerializer());
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder().addModule(module).build();
|
||||||
|
System.out.println("custom : "
|
||||||
|
+ mapper.writeValueAsString(new Ticket(new UserId("u-42"), "Fix build")));
|
||||||
|
System.out.println("default : "
|
||||||
|
+ JsonMapper.builder().build()
|
||||||
|
.writeValueAsString(new Ticket(new UserId("u-42"), "Fix build")));
|
||||||
|
System.out.println("base class: "
|
||||||
|
+ UserIdSerializer.class.getSuperclass().getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/java/com/ankurm/jackson3/part4custom/Money.java
Normal file
6
src/main/java/com/ankurm/jackson3/part4custom/Money.java
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package com.ankurm.jackson3.part4custom;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/** The domain value object used by the custom serialiser and deserialiser. */
|
||||||
|
public record Money(BigDecimal amount, String currencyCode) { }
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
public class BankTransferPayment extends PaymentMethod {
|
||||||
|
private String bankAccountIban;
|
||||||
|
private String bankName;
|
||||||
|
|
||||||
|
public String getBankAccountIban() { return bankAccountIban; }
|
||||||
|
public String getBankName() { return bankName; }
|
||||||
|
public void setBankAccountIban(String v) { this.bankAccountIban = v; }
|
||||||
|
public void setBankName(String v) { this.bankName = v; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
public class CreditCardPayment extends PaymentMethod {
|
||||||
|
private String cardNumberLastFour;
|
||||||
|
private String cardNetwork; // "VISA", "MASTERCARD", etc.
|
||||||
|
|
||||||
|
public String getCardNumberLastFour() { return cardNumberLastFour; }
|
||||||
|
public String getCardNetwork() { return cardNetwork; }
|
||||||
|
public void setCardNumberLastFour(String v) { this.cardNumberLastFour = v; }
|
||||||
|
public void setCardNetwork(String v) { this.cardNetwork = v; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
|
||||||
|
* Section: "Serialising a Mixed List"
|
||||||
|
*
|
||||||
|
* CORRECTION TO THE POST. The post shows
|
||||||
|
*
|
||||||
|
* mapper.writerWithDefaultPrettyPrinter().writeValueAsString(payments)
|
||||||
|
*
|
||||||
|
* producing JSON that contains "paymentType". It does not. Passing a List to
|
||||||
|
* writeValueAsString gives Jackson only the runtime class (ImmutableCollections.ListN),
|
||||||
|
* which carries no element type, so the polymorphic type serialiser is never engaged
|
||||||
|
* and the discriminator is silently omitted. The resulting JSON then fails to
|
||||||
|
* deserialise — see F02DeserialiseMixedList.
|
||||||
|
*
|
||||||
|
* Two things do work: a typed array, or writerFor(TypeReference).
|
||||||
|
*/
|
||||||
|
public class F01SerialiseMixedList {
|
||||||
|
|
||||||
|
static List<PaymentMethod> samplePayments() {
|
||||||
|
CreditCardPayment card = new CreditCardPayment();
|
||||||
|
card.setPaymentId(1L);
|
||||||
|
card.setAmountDue(99.99);
|
||||||
|
card.setCardNumberLastFour("4242");
|
||||||
|
card.setCardNetwork("VISA");
|
||||||
|
|
||||||
|
BankTransferPayment bank = new BankTransferPayment();
|
||||||
|
bank.setPaymentId(2L);
|
||||||
|
bank.setAmountDue(250.00);
|
||||||
|
bank.setBankAccountIban("GB29NWBK60161331926819");
|
||||||
|
bank.setBankName("National Bank");
|
||||||
|
|
||||||
|
return List.of(card, bank);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
List<PaymentMethod> payments = samplePayments();
|
||||||
|
|
||||||
|
System.out.println("--- 1. single element: discriminator present ---");
|
||||||
|
System.out.println(mapper.writeValueAsString(payments.get(0)));
|
||||||
|
|
||||||
|
System.out.println("--- 2. BROKEN: writeValueAsString(List) drops paymentType ---");
|
||||||
|
System.out.println(mapper.writeValueAsString(payments));
|
||||||
|
|
||||||
|
System.out.println("--- 3. FIX A: writerFor(TypeReference) ---");
|
||||||
|
System.out.println(mapper.writerFor(new TypeReference<List<PaymentMethod>>() { })
|
||||||
|
.withDefaultPrettyPrinter()
|
||||||
|
.writeValueAsString(payments));
|
||||||
|
|
||||||
|
System.out.println("--- 4. FIX B: a typed array carries its component type ---");
|
||||||
|
System.out.println(mapper.writeValueAsString(payments.toArray(new PaymentMethod[0])));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
|
||||||
|
* Section: "Deserialising a Mixed List"
|
||||||
|
*
|
||||||
|
* Deserialisation is the half that works exactly as the post describes — and it is
|
||||||
|
* also what proves the serialisation defect in F01: feed it the discriminator-less
|
||||||
|
* JSON and it fails outright.
|
||||||
|
*/
|
||||||
|
public class F02DeserialiseMixedList {
|
||||||
|
|
||||||
|
private static final String GOOD_JSON = "["
|
||||||
|
+ "{\"paymentType\":\"credit_card\",\"paymentId\":1,\"amountDue\":99.99,"
|
||||||
|
+ "\"cardNumberLastFour\":\"4242\",\"cardNetwork\":\"VISA\"},"
|
||||||
|
+ "{\"paymentType\":\"bank_transfer\",\"paymentId\":2,\"amountDue\":250.0,"
|
||||||
|
+ "\"bankAccountIban\":\"GB29NWBK60161331926819\",\"bankName\":\"National Bank\"}"
|
||||||
|
+ "]";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
List<PaymentMethod> payments =
|
||||||
|
mapper.readValue(GOOD_JSON, new TypeReference<List<PaymentMethod>>() { });
|
||||||
|
|
||||||
|
for (PaymentMethod payment : payments) {
|
||||||
|
if (payment instanceof CreditCardPayment cc) {
|
||||||
|
System.out.println("Card ending: " + cc.getCardNumberLastFour());
|
||||||
|
} else if (payment instanceof BankTransferPayment bt) {
|
||||||
|
System.out.println("Bank: " + bt.getBankName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now prove the F01 defect matters: the lossy output cannot be read back.
|
||||||
|
String lossy = mapper.writeValueAsString(F01SerialiseMixedList.samplePayments());
|
||||||
|
try {
|
||||||
|
mapper.readValue(lossy, new TypeReference<List<PaymentMethod>>() { });
|
||||||
|
System.out.println("unreachable");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("lossy JSON round-trip -> " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whereas the correctly written output does round-trip.
|
||||||
|
String correct = mapper.writerFor(new TypeReference<List<PaymentMethod>>() { })
|
||||||
|
.writeValueAsString(F01SerialiseMixedList.samplePayments());
|
||||||
|
List<PaymentMethod> again =
|
||||||
|
mapper.readValue(correct, new TypeReference<List<PaymentMethod>>() { });
|
||||||
|
System.out.println("correct JSON round-trip -> " + again.size() + " payments, "
|
||||||
|
+ again.get(0).getClass().getSimpleName() + " first");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
|
||||||
|
* Section: "@JsonTypeInfo Placement Options"
|
||||||
|
*
|
||||||
|
* The post gives a table of the four include strategies. This runs all four so you
|
||||||
|
* can see the actual wire format instead of trusting the table.
|
||||||
|
*/
|
||||||
|
public class F03IncludeStrategies {
|
||||||
|
|
||||||
|
// ---- As.PROPERTY: discriminator is an ordinary field inside the object ----
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "kind")
|
||||||
|
@JsonSubTypes(@JsonSubTypes.Type(value = PropCard.class, name = "card"))
|
||||||
|
public interface PropBase { }
|
||||||
|
public record PropCard(double amountDue) implements PropBase { }
|
||||||
|
|
||||||
|
// ---- As.WRAPPER_OBJECT: object wrapped in a single-key envelope ----
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
|
||||||
|
@JsonSubTypes(@JsonSubTypes.Type(value = WrapObjCard.class, name = "card"))
|
||||||
|
public interface WrapObjBase { }
|
||||||
|
public record WrapObjCard(double amountDue) implements WrapObjBase { }
|
||||||
|
|
||||||
|
// ---- As.WRAPPER_ARRAY: two-element [name, object] array ----
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_ARRAY)
|
||||||
|
@JsonSubTypes(@JsonSubTypes.Type(value = WrapArrCard.class, name = "card"))
|
||||||
|
public interface WrapArrBase { }
|
||||||
|
public record WrapArrCard(double amountDue) implements WrapArrBase { }
|
||||||
|
|
||||||
|
// ---- As.EXISTING_PROPERTY: reuses a field the class already declares ----
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY,
|
||||||
|
property = "kind", visible = true)
|
||||||
|
@JsonSubTypes(@JsonSubTypes.Type(value = ExistingCard.class, name = "card"))
|
||||||
|
public interface ExistingBase { String kind(); }
|
||||||
|
public record ExistingCard(String kind, double amountDue) implements ExistingBase { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
System.out.println("PROPERTY : " + mapper.writeValueAsString((PropBase) new PropCard(99.0)));
|
||||||
|
System.out.println("WRAPPER_OBJECT : " + mapper.writeValueAsString((WrapObjBase) new WrapObjCard(99.0)));
|
||||||
|
System.out.println("WRAPPER_ARRAY : " + mapper.writeValueAsString((WrapArrBase) new WrapArrCard(99.0)));
|
||||||
|
System.out.println("EXISTING_PROPERTY : " + mapper.writeValueAsString((ExistingBase) new ExistingCard("card", 99.0)));
|
||||||
|
|
||||||
|
// Each form reads back to the correct concrete type.
|
||||||
|
System.out.println("read PROPERTY -> " + mapper.readValue("{\"kind\":\"card\",\"amountDue\":99.0}", PropBase.class));
|
||||||
|
System.out.println("read WRAPPER_OBJECT -> " + mapper.readValue("{\"card\":{\"amountDue\":99.0}}", WrapObjBase.class));
|
||||||
|
System.out.println("read WRAPPER_ARRAY -> " + mapper.readValue("[\"card\",{\"amountDue\":99.0}]", WrapArrBase.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — what actually happens when the discriminator is wrong.
|
||||||
|
*
|
||||||
|
* The security post asserts that an unregistered type name throws
|
||||||
|
* InvalidTypeIdException. This runs the three failure modes so the exception types
|
||||||
|
* are on the record rather than assumed: unknown name, attacker-supplied class name,
|
||||||
|
* and a missing discriminator.
|
||||||
|
*/
|
||||||
|
public class F04UnknownTypeId {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
attempt(mapper, "unknown logical name",
|
||||||
|
"{\"paymentType\":\"crypto\",\"paymentId\":9,\"amountDue\":1.0}");
|
||||||
|
|
||||||
|
attempt(mapper, "attacker-supplied class name",
|
||||||
|
"{\"paymentType\":\"com.malicious.Gadget\",\"paymentId\":9,\"amountDue\":1.0}");
|
||||||
|
|
||||||
|
attempt(mapper, "missing discriminator",
|
||||||
|
"{\"paymentId\":9,\"amountDue\":1.0}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void attempt(JsonMapper mapper, String label, String json) {
|
||||||
|
try {
|
||||||
|
PaymentMethod result = mapper.readValue(json, PaymentMethod.class);
|
||||||
|
System.out.printf("%-30s -> UNEXPECTEDLY OK: %s%n", label, result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.printf("%-30s -> %s%n", label, e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.ankurm.jackson3.part5polymorphic;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
|
||||||
|
* Section: "Setting Up the Hierarchy with @JsonTypeInfo and @JsonSubTypes"
|
||||||
|
*/
|
||||||
|
@JsonTypeInfo(
|
||||||
|
use = JsonTypeInfo.Id.NAME, // use a logical name as the discriminator
|
||||||
|
include = JsonTypeInfo.As.PROPERTY, // embed it as a field in the JSON object
|
||||||
|
property = "paymentType" // the JSON key that carries the type name
|
||||||
|
)
|
||||||
|
@JsonSubTypes({
|
||||||
|
@JsonSubTypes.Type(value = CreditCardPayment.class, name = "credit_card"),
|
||||||
|
@JsonSubTypes.Type(value = BankTransferPayment.class, name = "bank_transfer")
|
||||||
|
})
|
||||||
|
public abstract class PaymentMethod {
|
||||||
|
private Long paymentId;
|
||||||
|
private double amountDue;
|
||||||
|
|
||||||
|
public Long getPaymentId() { return paymentId; }
|
||||||
|
public double getAmountDue() { return amountDue; }
|
||||||
|
public void setPaymentId(Long v) { this.paymentId = v; }
|
||||||
|
public void setAmountDue(double v) { this.amountDue = v; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.ankurm.jackson3.part6streaming;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser;
|
||||||
|
import tools.jackson.core.JsonToken;
|
||||||
|
import tools.jackson.core.json.JsonFactory;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
|
||||||
|
* Section: "Reading a Large JSON Array with JsonParser"
|
||||||
|
*
|
||||||
|
* Jackson 3 differences from the post's code:
|
||||||
|
* 1. JsonFactory is in tools.jackson.core.json — NOT tools.jackson.core.
|
||||||
|
* 2. parser.getCurrentName() is now parser.currentName().
|
||||||
|
* 3. parser.getText() is now parser.getString().
|
||||||
|
* 4. JsonToken.FIELD_NAME is now JsonToken.PROPERTY_NAME.
|
||||||
|
* 5. No `throws IOException` — Jackson 3 exceptions are unchecked.
|
||||||
|
*/
|
||||||
|
public class G01StreamingParserFilter {
|
||||||
|
|
||||||
|
private static final String SAMPLE = """
|
||||||
|
[
|
||||||
|
{"level":"INFO","message":"Application started"},
|
||||||
|
{"level":"ERROR","message":"Database connection failed"},
|
||||||
|
{"level":"INFO","message":"Retrying connection"}
|
||||||
|
]
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
File logFile = File.createTempFile("large-logs", ".json");
|
||||||
|
Files.writeString(logFile.toPath(), SAMPLE);
|
||||||
|
logFile.deleteOnExit();
|
||||||
|
|
||||||
|
JsonFactory jsonFactory = new JsonFactory();
|
||||||
|
int errorCount = 0;
|
||||||
|
|
||||||
|
try (JsonParser parser = jsonFactory.createParser(tools.jackson.core.ObjectReadContext.empty(), logFile)) {
|
||||||
|
|
||||||
|
// Confirm the root is an array
|
||||||
|
if (parser.nextToken() != JsonToken.START_ARRAY) {
|
||||||
|
throw new IllegalStateException("Expected a JSON array at the root");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk each element in the array
|
||||||
|
while (parser.nextToken() != JsonToken.END_ARRAY) {
|
||||||
|
|
||||||
|
String logLevel = null;
|
||||||
|
String logMessage = null;
|
||||||
|
|
||||||
|
// Walk each property inside the current object
|
||||||
|
while (parser.nextToken() != JsonToken.END_OBJECT) {
|
||||||
|
String fieldName = parser.currentName();
|
||||||
|
parser.nextToken(); // move to the value
|
||||||
|
|
||||||
|
if ("level".equals(fieldName)) {
|
||||||
|
logLevel = parser.getString();
|
||||||
|
} else if ("message".equals(fieldName)) {
|
||||||
|
logMessage = parser.getString();
|
||||||
|
}
|
||||||
|
// All other fields are skipped automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("ERROR".equals(logLevel)) {
|
||||||
|
System.out.println("ERROR: " + logMessage);
|
||||||
|
errorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
System.out.println("Total errors found: " + errorCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.ankurm.jackson3.part6streaming;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonEncoding;
|
||||||
|
import tools.jackson.core.JsonGenerator;
|
||||||
|
import tools.jackson.core.ObjectWriteContext;
|
||||||
|
import tools.jackson.core.json.JsonFactory;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
|
||||||
|
* Section: "Writing JSON with JsonGenerator"
|
||||||
|
*
|
||||||
|
* Jackson 3 differences: writeNumberField/writeStringField are now
|
||||||
|
* writeNumberProperty/writeStringProperty, and the factory needs an
|
||||||
|
* ObjectWriteContext. The post's 1,000,000-record loop is kept — it is the whole
|
||||||
|
* point of streaming — and the peak heap is measured so "constant memory" is a
|
||||||
|
* number rather than a claim.
|
||||||
|
*/
|
||||||
|
public class G02StreamingGenerator {
|
||||||
|
|
||||||
|
private static final int RECORD_COUNT = 1_000_000;
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
File output = File.createTempFile("output", ".json");
|
||||||
|
output.deleteOnExit();
|
||||||
|
|
||||||
|
JsonFactory jsonFactory = new JsonFactory();
|
||||||
|
Runtime runtime = Runtime.getRuntime();
|
||||||
|
long before = runtime.totalMemory() - runtime.freeMemory();
|
||||||
|
long start = System.nanoTime();
|
||||||
|
|
||||||
|
try (JsonGenerator generator = jsonFactory.createGenerator(
|
||||||
|
ObjectWriteContext.empty(), output, JsonEncoding.UTF8)) {
|
||||||
|
|
||||||
|
generator.writeStartArray();
|
||||||
|
for (int recordIndex = 0; recordIndex < RECORD_COUNT; recordIndex++) {
|
||||||
|
generator.writeStartObject();
|
||||||
|
generator.writeNumberProperty("id", recordIndex);
|
||||||
|
generator.writeStringProperty("status", "active");
|
||||||
|
generator.writeEndObject();
|
||||||
|
}
|
||||||
|
generator.writeEndArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
long elapsedMs = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
long after = runtime.totalMemory() - runtime.freeMemory();
|
||||||
|
|
||||||
|
System.out.println("records written : " + RECORD_COUNT);
|
||||||
|
System.out.println("file size : " + (output.length() / 1024 / 1024) + " MB");
|
||||||
|
System.out.println("elapsed : " + elapsedMs + " ms");
|
||||||
|
System.out.println("heap delta : " + ((after - before) / 1024 / 1024) + " MB"
|
||||||
|
+ " <- the document is never held in memory");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.ankurm.jackson3.part6streaming;
|
||||||
|
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Streaming API and JsonNode — https://ankurm.com/jackson-streaming-api-jsonnode/
|
||||||
|
* Sections: "The Tree Model" and "Mixing Tree Model with Data Binding"
|
||||||
|
*
|
||||||
|
* Jackson 3 difference: JsonNode.asText() is now asString(). asInt() survives.
|
||||||
|
*/
|
||||||
|
public class G03TreeModelNavigation {
|
||||||
|
|
||||||
|
public record CustomerRecord(String name, String tier) { }
|
||||||
|
|
||||||
|
private static final String JSON = "{"
|
||||||
|
+ "\"orderId\":1001,"
|
||||||
|
+ "\"customer\":{\"name\":\"Alice\",\"tier\":\"gold\"},"
|
||||||
|
+ "\"items\":[{\"sku\":\"KB-01\",\"qty\":2},{\"sku\":\"MS-42\",\"qty\":1}]"
|
||||||
|
+ "}";
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
JsonNode rootNode = mapper.readTree(JSON);
|
||||||
|
|
||||||
|
// path() never returns null — a missing node is a MissingNode.
|
||||||
|
String customerName = rootNode.path("customer").path("name").asString();
|
||||||
|
System.out.println("Customer: " + customerName);
|
||||||
|
|
||||||
|
for (JsonNode itemNode : rootNode.path("items")) {
|
||||||
|
System.out.println(itemNode.path("sku").asString() + " x" + itemNode.path("qty").asInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("Has discount: " + rootNode.has("discountCode"));
|
||||||
|
|
||||||
|
// path() vs get() on an absent field — the difference that causes NPEs.
|
||||||
|
System.out.println("path(missing) : " + rootNode.path("nope")
|
||||||
|
+ " (class " + rootNode.path("nope").getClass().getSimpleName() + ")");
|
||||||
|
System.out.println("get(missing) : " + rootNode.get("nope"));
|
||||||
|
|
||||||
|
// Deep navigation stays null-safe all the way down.
|
||||||
|
System.out.println("deep path : '"
|
||||||
|
+ rootNode.path("a").path("b").path("c").asString("<default>") + "'");
|
||||||
|
|
||||||
|
// Switch from tree to data binding at any node.
|
||||||
|
CustomerRecord customer = mapper.treeToValue(rootNode.path("customer"), CustomerRecord.class);
|
||||||
|
System.out.println("treeToValue : " + customer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package com.ankurm.jackson3.part6streaming;
|
||||||
|
|
||||||
|
import tools.jackson.core.JsonParser;
|
||||||
|
import tools.jackson.core.JsonToken;
|
||||||
|
import tools.jackson.core.json.JsonFactory;
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.JsonNode;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — the decision table, measured.
|
||||||
|
*
|
||||||
|
* The post ends with a table claiming data binding "loads full object", tree model
|
||||||
|
* "loads full tree", and streaming uses "constant" memory. This generates a real
|
||||||
|
* file and measures all three so the table has numbers behind it.
|
||||||
|
*
|
||||||
|
* These are indicative single-shot measurements on one JVM, not JMH benchmarks —
|
||||||
|
* the ordering is the point, not the absolute figures.
|
||||||
|
*/
|
||||||
|
public class G04ThreeApproachesMeasured {
|
||||||
|
|
||||||
|
public record LogEntry(long id, String level, String message) { }
|
||||||
|
|
||||||
|
private static final int ENTRIES = 200_000;
|
||||||
|
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
File file = generate();
|
||||||
|
System.out.println("input file : " + (file.length() / 1024 / 1024) + " MB, "
|
||||||
|
+ ENTRIES + " entries");
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
measure("data binding (readValue)", () -> {
|
||||||
|
List<LogEntry> all = mapper.readValue(file, new TypeReference<List<LogEntry>>() { });
|
||||||
|
return all.stream().filter(e -> e.level().equals("ERROR")).count();
|
||||||
|
});
|
||||||
|
|
||||||
|
measure("tree model (readTree)", () -> {
|
||||||
|
JsonNode root = mapper.readTree(file);
|
||||||
|
long n = 0;
|
||||||
|
for (JsonNode node : root) if ("ERROR".equals(node.path("level").asString())) n++;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
measure("streaming (JsonParser)", () -> {
|
||||||
|
long n = 0;
|
||||||
|
JsonFactory factory = new JsonFactory();
|
||||||
|
try (JsonParser p = factory.createParser(tools.jackson.core.ObjectReadContext.empty(), file)) {
|
||||||
|
p.nextToken();
|
||||||
|
while (p.nextToken() != JsonToken.END_ARRAY) {
|
||||||
|
String level = null;
|
||||||
|
while (p.nextToken() != JsonToken.END_OBJECT) {
|
||||||
|
String f = p.currentName();
|
||||||
|
p.nextToken();
|
||||||
|
if ("level".equals(f)) level = p.getString();
|
||||||
|
}
|
||||||
|
if ("ERROR".equals(level)) n++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private interface Counter { long count() throws Exception; }
|
||||||
|
|
||||||
|
private static void measure(String label, Counter counter) throws Exception {
|
||||||
|
Runtime rt = Runtime.getRuntime();
|
||||||
|
System.gc();
|
||||||
|
Thread.sleep(120);
|
||||||
|
long heapBefore = rt.totalMemory() - rt.freeMemory();
|
||||||
|
long start = System.nanoTime();
|
||||||
|
long errors = counter.count();
|
||||||
|
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
long heapAfter = rt.totalMemory() - rt.freeMemory();
|
||||||
|
System.out.printf("%-28s errors=%-6d %5d ms heap delta %5d MB%n",
|
||||||
|
label, errors, ms, (heapAfter - heapBefore) / 1024 / 1024);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File generate() throws Exception {
|
||||||
|
File f = File.createTempFile("logs", ".json");
|
||||||
|
f.deleteOnExit();
|
||||||
|
StringBuilder sb = new StringBuilder("[");
|
||||||
|
for (int i = 0; i < ENTRIES; i++) {
|
||||||
|
if (i > 0) sb.append(',');
|
||||||
|
sb.append("{\"id\":").append(i)
|
||||||
|
.append(",\"level\":\"").append(i % 50 == 0 ? "ERROR" : "INFO")
|
||||||
|
.append("\",\"message\":\"event number ").append(i)
|
||||||
|
.append(" with some padding to make the payload realistic\"}");
|
||||||
|
}
|
||||||
|
sb.append(']');
|
||||||
|
Files.writeString(f.toPath(), sb);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ankurm.jackson3.part7security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
|
||||||
|
* Section: "Use @JsonTypeInfo Instead of Default Typing"
|
||||||
|
*
|
||||||
|
* The safe pattern: the permitted types are fixed at compile time, so no JSON payload
|
||||||
|
* can introduce a class name of its own.
|
||||||
|
*/
|
||||||
|
public class H01SafePolymorphismByAnnotation {
|
||||||
|
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
|
||||||
|
@JsonSubTypes({
|
||||||
|
@JsonSubTypes.Type(value = EmailNotification.class, name = "email"),
|
||||||
|
@JsonSubTypes.Type(value = SmsNotification.class, name = "sms")
|
||||||
|
})
|
||||||
|
public abstract static class Notification { }
|
||||||
|
|
||||||
|
public static class EmailNotification extends Notification {
|
||||||
|
public String recipientEmail;
|
||||||
|
@Override public String toString() { return "EmailNotification[" + recipientEmail + "]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SmsNotification extends Notification {
|
||||||
|
public String recipientPhone;
|
||||||
|
@Override public String toString() { return "SmsNotification[" + recipientPhone + "]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
System.out.println("email : " + mapper.readValue(
|
||||||
|
"{\"type\":\"email\",\"recipientEmail\":\"a@example.com\"}", Notification.class));
|
||||||
|
System.out.println("sms : " + mapper.readValue(
|
||||||
|
"{\"type\":\"sms\",\"recipientPhone\":\"+441234567890\"}", Notification.class));
|
||||||
|
|
||||||
|
// A class name supplied by an attacker is not a registered logical name.
|
||||||
|
try {
|
||||||
|
mapper.readValue("{\"type\":\"com.malicious.Gadget\"}", Notification.class);
|
||||||
|
System.out.println("attack: UNEXPECTEDLY ACCEPTED");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("attack: rejected with " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ankurm.jackson3.part7security;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
|
||||||
|
* Section: "The Safe Alternative: PolymorphicTypeValidator"
|
||||||
|
*
|
||||||
|
* CORRECTION TO THE POST. The post shows the remediation as
|
||||||
|
*
|
||||||
|
* ObjectMapper mapper = new ObjectMapper();
|
||||||
|
* mapper.activateDefaultTyping(validator, DefaultTyping.NON_FINAL, As.PROPERTY);
|
||||||
|
*
|
||||||
|
* That is Jackson 2 code. In Jackson 3 BOTH enableDefaultTyping and
|
||||||
|
* activateDefaultTyping are absent from the mapper — the mapper has no mutators at
|
||||||
|
* all. activateDefaultTyping survives only on JsonMapper.Builder. This prints the
|
||||||
|
* reflective proof for each claim rather than asserting it.
|
||||||
|
*
|
||||||
|
* See H03PolymorphicTypeValidatorAllowlist for the working builder-based form.
|
||||||
|
*/
|
||||||
|
public class H02DefaultTypingRemoved {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
System.out.println("--- tools.jackson.databind.ObjectMapper ---");
|
||||||
|
report(ObjectMapper.class, "enableDefaultTyping");
|
||||||
|
report(ObjectMapper.class, "activateDefaultTyping");
|
||||||
|
report(ObjectMapper.class, "setSerializationInclusion");
|
||||||
|
report(ObjectMapper.class, "registerModule");
|
||||||
|
report(ObjectMapper.class, "addMixIn");
|
||||||
|
System.out.println("total set*() mutators: " + Arrays.stream(ObjectMapper.class.getMethods())
|
||||||
|
.filter(m -> m.getName().startsWith("set")).count());
|
||||||
|
|
||||||
|
System.out.println();
|
||||||
|
System.out.println("--- tools.jackson.databind.json.JsonMapper.Builder ---");
|
||||||
|
report(JsonMapper.Builder.class, "activateDefaultTyping");
|
||||||
|
report(JsonMapper.Builder.class, "deactivateDefaultTyping");
|
||||||
|
report(JsonMapper.Builder.class, "polymorphicTypeValidator");
|
||||||
|
report(JsonMapper.Builder.class, "changeDefaultPropertyInclusion");
|
||||||
|
report(JsonMapper.Builder.class, "serializationInclusion");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void report(Class<?> type, String methodName) {
|
||||||
|
boolean present = Arrays.stream(type.getMethods())
|
||||||
|
.map(Method::getName)
|
||||||
|
.anyMatch(methodName::equals);
|
||||||
|
System.out.printf(" %-32s %s%n", methodName, present ? "present" : "ABSENT");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.ankurm.jackson3.part7security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||||
|
import tools.jackson.databind.DefaultTyping;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
|
||||||
|
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
|
||||||
|
* Section: "The Safe Alternative: PolymorphicTypeValidator"
|
||||||
|
*
|
||||||
|
* The post's snippet in working Jackson 3 form. Note DefaultTyping is a top-level
|
||||||
|
* enum in tools.jackson.databind, not ObjectMapper.DefaultTyping as in Jackson 2.
|
||||||
|
*
|
||||||
|
* Default typing remains a last resort. Prefer H01. This exists because legacy object
|
||||||
|
* graphs and plugin systems sometimes genuinely need it, and when they do, the
|
||||||
|
* allowlist has to be provable — hence the negative test at the bottom.
|
||||||
|
*/
|
||||||
|
public class H03PolymorphicTypeValidatorAllowlist {
|
||||||
|
|
||||||
|
public abstract static class BasePayload { }
|
||||||
|
public static class SafePayload extends BasePayload {
|
||||||
|
public String note;
|
||||||
|
public SafePayload() { }
|
||||||
|
public SafePayload(String n) { note = n; }
|
||||||
|
@Override public String toString() { return "SafePayload[" + note + "]"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deliberately outside the allowlisted base type. */
|
||||||
|
public static class RoguePayload {
|
||||||
|
public String note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Envelope {
|
||||||
|
public Object body; // the field default typing has to resolve
|
||||||
|
public Envelope() { }
|
||||||
|
public Envelope(Object b) { body = b; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
// Permit ONLY the envelope and our own payload hierarchy. Anything else is
|
||||||
|
// refused at type-resolution time, before any class is instantiated.
|
||||||
|
//
|
||||||
|
// Note the gotcha: with DefaultTyping.NON_FINAL, Jackson writes a type id for
|
||||||
|
// the ROOT object too, so Envelope must be allowlisted as well. Allowlisting
|
||||||
|
// only BasePayload makes even the happy path fail — which is how most people
|
||||||
|
// first meet this API.
|
||||||
|
PolymorphicTypeValidator safeTypeValidator = BasicPolymorphicTypeValidator.builder()
|
||||||
|
.allowIfSubType(Envelope.class)
|
||||||
|
.allowIfSubType(BasePayload.class)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
JsonMapper mapper = JsonMapper.builder()
|
||||||
|
.activateDefaultTyping(safeTypeValidator,
|
||||||
|
DefaultTyping.NON_FINAL,
|
||||||
|
JsonTypeInfo.As.PROPERTY)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
String allowed = mapper.writeValueAsString(new Envelope(new SafePayload("ok")));
|
||||||
|
System.out.println("allowed written : " + allowed);
|
||||||
|
System.out.println("allowed read : "
|
||||||
|
+ ((Envelope) mapper.readValue(allowed, Envelope.class)).body);
|
||||||
|
|
||||||
|
// Negative test: a class outside the allowlist is rejected even though it
|
||||||
|
// exists on the classpath and would deserialise perfectly well otherwise.
|
||||||
|
String rogue = "{\"body\":[\"" + RoguePayload.class.getName() + "\",{\"note\":\"pwn\"}]}";
|
||||||
|
try {
|
||||||
|
mapper.readValue(rogue, Envelope.class);
|
||||||
|
System.out.println("rogue : UNEXPECTEDLY ACCEPTED");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("rogue : rejected with " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.jackson3.part7security;
|
||||||
|
|
||||||
|
import tools.jackson.core.StreamReadConstraints;
|
||||||
|
import tools.jackson.core.json.JsonFactory;
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEYOND THE POST — the hardening the security post does not mention.
|
||||||
|
*
|
||||||
|
* Gadget attacks are not the only deserialisation risk. A small payload can also
|
||||||
|
* exhaust the stack or the heap: deeply nested arrays, gigantic numbers, enormous
|
||||||
|
* strings. Jackson 3 ships StreamReadConstraints with defaults for all three, and
|
||||||
|
* they are tunable. Any service accepting external JSON should know what they are.
|
||||||
|
*/
|
||||||
|
public class H04StreamReadConstraints {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
StreamReadConstraints defaults = StreamReadConstraints.defaults();
|
||||||
|
System.out.println("--- Jackson 3 defaults ---");
|
||||||
|
System.out.println("max nesting depth : " + defaults.getMaxNestingDepth());
|
||||||
|
System.out.println("max number length : " + defaults.getMaxNumberLength());
|
||||||
|
System.out.println("max string length : " + defaults.getMaxStringLength());
|
||||||
|
System.out.println("max name length : " + defaults.getMaxNameLength());
|
||||||
|
System.out.println("max doc length : " + defaults.getMaxDocumentLength()
|
||||||
|
+ " (-1 = unlimited)");
|
||||||
|
System.out.println();
|
||||||
|
|
||||||
|
JsonMapper plain = JsonMapper.builder().build();
|
||||||
|
String deep = "[".repeat(1200) + "]".repeat(1200);
|
||||||
|
System.out.println("1200-deep nesting, default limits -> " + attempt(plain, deep));
|
||||||
|
|
||||||
|
// Tighten the limits for an endpoint that should never see nested data.
|
||||||
|
JsonFactory strictFactory = JsonFactory.builder()
|
||||||
|
.streamReadConstraints(StreamReadConstraints.builder()
|
||||||
|
.maxNestingDepth(10)
|
||||||
|
.maxStringLength(2_000)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
JsonMapper strict = JsonMapper.builder(strictFactory).build();
|
||||||
|
|
||||||
|
System.out.println("20-deep nesting, strict limits -> "
|
||||||
|
+ attempt(strict, "[".repeat(20) + "]".repeat(20)));
|
||||||
|
System.out.println("5-deep nesting, strict limits -> "
|
||||||
|
+ attempt(strict, "[".repeat(5) + "]".repeat(5)));
|
||||||
|
System.out.println("3KB string, strict limits -> "
|
||||||
|
+ attempt(strict, "\"" + "x".repeat(3_000) + "\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String attempt(JsonMapper mapper, String json) {
|
||||||
|
try {
|
||||||
|
mapper.readTree(json);
|
||||||
|
return "accepted";
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "rejected (" + e.getClass().getSimpleName() + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.ankurm.jackson3.part7security;
|
||||||
|
|
||||||
|
import tools.jackson.databind.json.JsonMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post: Jackson Security Best Practices — https://ankurm.com/jackson-security-best-practices/
|
||||||
|
* Section: "Never Deserialise Untrusted JSON into Object.class"
|
||||||
|
*
|
||||||
|
* Worth knowing precisely what readValue(json, Object.class) does on a DEFAULT
|
||||||
|
* Jackson 3 mapper, because the answer is reassuring and often misunderstood:
|
||||||
|
* with no default typing active it produces plain Maps, Lists, Strings and numbers.
|
||||||
|
* The danger only returns when default typing is switched on — as H03 shows.
|
||||||
|
*
|
||||||
|
* The rule still stands. Target a specific type; you get validation for free.
|
||||||
|
*/
|
||||||
|
public class H05NeverDeserialiseIntoObject {
|
||||||
|
|
||||||
|
public record MyRequestDto(String action, int quantity) { }
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
JsonMapper mapper = JsonMapper.builder().build();
|
||||||
|
|
||||||
|
String untrusted = "{\"action\":\"ship\",\"quantity\":3,\"extra\":{\"nested\":[1,2]}}";
|
||||||
|
|
||||||
|
Object loose = mapper.readValue(untrusted, Object.class);
|
||||||
|
System.out.println("as Object : " + loose);
|
||||||
|
System.out.println("runtime type: " + loose.getClass().getName()
|
||||||
|
+ " <- a plain Map, no arbitrary class was instantiated");
|
||||||
|
|
||||||
|
MyRequestDto typed = mapper.readValue(untrusted, MyRequestDto.class);
|
||||||
|
System.out.println("as DTO : " + typed);
|
||||||
|
|
||||||
|
// The real benefit of a specific target type: malformed input fails loudly
|
||||||
|
// instead of flowing onward as an untyped Map.
|
||||||
|
try {
|
||||||
|
mapper.readValue("{\"action\":\"ship\",\"quantity\":\"not-a-number\"}", MyRequestDto.class);
|
||||||
|
System.out.println("bad input : UNEXPECTEDLY ACCEPTED");
|
||||||
|
} catch (Exception e) {
|
||||||
|
System.out.println("bad input : rejected with " + e.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user