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