From c438afc33b562772df55cda640d0a4116f50acf4 Mon Sep 17 00:00:00 2001 From: Ankur Date: Tue, 4 Aug 2026 23:12:11 +0530 Subject: [PATCH] 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) silently drops the polymorphic type discriminator, so the post's serialised output cannot be read back. --- .gitignore | 3 + README.md | 123 +++++++++++++++ docs/README.md | 18 +++ docs/beyond.md | 149 ++++++++++++++++++ docs/output/A01FirstRoundTrip.txt | 2 + docs/output/A02SharedMapperConfiguration.txt | 3 + docs/output/A03ThreeProcessingModels.txt | 3 + docs/output/B01WriteJson.txt | 8 + docs/output/B02ReadJson.txt | 3 + docs/output/B03GenericCollections.txt | 6 + docs/output/B04PropertyOrdering.txt | 3 + docs/output/C01RecordRoundTrip.txt | 3 + docs/output/C02OptionalFields.txt | 5 + docs/output/C03SealedWithSubTypes.txt | 4 + docs/output/C04SealedAutoDiscovery.txt | 3 + docs/output/D01RenameAndIgnore.txt | 4 + docs/output/D02InclusionAndFormat.txt | 3 + docs/output/D03AliasAndUnknownFields.txt | 6 + docs/output/D04CreatorsAndUnwrapping.txt | 5 + docs/output/E03SimpleModuleRegistration.txt | 5 + docs/output/E04MixinAnnotations.txt | 2 + docs/output/E05ValueSerializerDirect.txt | 3 + docs/output/F01SerialiseMixedList.txt | 20 +++ docs/output/F02DeserialiseMixedList.txt | 4 + docs/output/F03IncludeStrategies.txt | 7 + docs/output/F04UnknownTypeId.txt | 3 + docs/output/G01StreamingParserFilter.txt | 2 + docs/output/G02StreamingGenerator.txt | 4 + docs/output/G03TreeModelNavigation.txt | 8 + docs/output/G04ThreeApproachesMeasured.txt | 5 + .../H01SafePolymorphismByAnnotation.txt | 3 + docs/output/H02DefaultTypingRemoved.txt | 14 ++ .../H03PolymorphicTypeValidatorAllowlist.txt | 3 + docs/output/H04StreamReadConstraints.txt | 11 ++ docs/output/H05NeverDeserialiseIntoObject.txt | 4 + docs/output/Y01UncheckedExceptions.txt | 9 ++ docs/output/Y02TrailingTokens.txt | 6 + docs/output/Y03DateTimeDefaults.txt | 8 + docs/output/Y04ImmutableMapperAndReaders.txt | 5 + docs/output/Y05CreatorDetection.txt | 6 + docs/output/Y06RecyclerPoolTuning.txt | 13 ++ docs/part0-setup.md | 60 +++++++ docs/part1-objectmapper.md | 69 ++++++++ docs/part2-modern-java.md | 70 ++++++++ docs/part3-annotations.md | 74 +++++++++ docs/part4-custom.md | 72 +++++++++ docs/part5-polymorphic.md | 111 +++++++++++++ docs/part6-streaming.md | 80 ++++++++++ docs/part7-security.md | 116 ++++++++++++++ pom.xml | 56 +++++++ run-all.sh | 27 ++++ .../beyond/Y01UncheckedExceptions.java | 79 ++++++++++ .../jackson3/beyond/Y02TrailingTokens.java | 46 ++++++ .../jackson3/beyond/Y03DateTimeDefaults.java | 62 ++++++++ .../beyond/Y04ImmutableMapperAndReaders.java | 56 +++++++ .../jackson3/beyond/Y05CreatorDetection.java | 55 +++++++ .../beyond/Y06RecyclerPoolTuning.java | 69 ++++++++ .../part0setup/A01FirstRoundTrip.java | 49 ++++++ .../A02SharedMapperConfiguration.java | 64 ++++++++ .../part0setup/A03ThreeProcessingModels.java | 45 ++++++ .../part1objectmapper/B01WriteJson.java | 39 +++++ .../part1objectmapper/B02ReadJson.java | 49 ++++++ .../B03GenericCollections.java | 54 +++++++ .../B04PropertyOrdering.java | 50 ++++++ .../part2modernjava/C01RecordRoundTrip.java | 36 +++++ .../part2modernjava/C02OptionalFields.java | 51 ++++++ .../C03SealedWithSubTypes.java | 57 +++++++ .../C04SealedAutoDiscovery.java | 40 +++++ .../part3annotations/D01RenameAndIgnore.java | 44 ++++++ .../D02InclusionAndFormat.java | 46 ++++++ .../D03AliasAndUnknownFields.java | 55 +++++++ .../D04CreatorsAndUnwrapping.java | 71 +++++++++ .../part4custom/E01MoneyValueSerializer.java | 38 +++++ .../E02MoneyValueDeserializer.java | 35 ++++ .../E03SimpleModuleRegistration.java | 57 +++++++ .../part4custom/E04MixinAnnotations.java | 48 ++++++ .../part4custom/E05ValueSerializerDirect.java | 43 +++++ .../ankurm/jackson3/part4custom/Money.java | 6 + .../part5polymorphic/BankTransferPayment.java | 11 ++ .../part5polymorphic/CreditCardPayment.java | 11 ++ .../F01SerialiseMixedList.java | 60 +++++++ .../F02DeserialiseMixedList.java | 56 +++++++ .../F03IncludeStrategies.java | 54 +++++++ .../part5polymorphic/F04UnknownTypeId.java | 36 +++++ .../part5polymorphic/PaymentMethod.java | 27 ++++ .../G01StreamingParserFilter.java | 73 +++++++++ .../part6streaming/G02StreamingGenerator.java | 55 +++++++ .../G03TreeModelNavigation.java | 50 ++++++ .../G04ThreeApproachesMeasured.java | 101 ++++++++++++ .../H01SafePolymorphismByAnnotation.java | 49 ++++++ .../H02DefaultTypingRemoved.java | 52 ++++++ .../H03PolymorphicTypeValidatorAllowlist.java | 75 +++++++++ .../H04StreamReadConstraints.java | 57 +++++++ .../H05NeverDeserialiseIntoObject.java | 42 +++++ 94 files changed, 3387 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/beyond.md create mode 100644 docs/output/A01FirstRoundTrip.txt create mode 100644 docs/output/A02SharedMapperConfiguration.txt create mode 100644 docs/output/A03ThreeProcessingModels.txt create mode 100644 docs/output/B01WriteJson.txt create mode 100644 docs/output/B02ReadJson.txt create mode 100644 docs/output/B03GenericCollections.txt create mode 100644 docs/output/B04PropertyOrdering.txt create mode 100644 docs/output/C01RecordRoundTrip.txt create mode 100644 docs/output/C02OptionalFields.txt create mode 100644 docs/output/C03SealedWithSubTypes.txt create mode 100644 docs/output/C04SealedAutoDiscovery.txt create mode 100644 docs/output/D01RenameAndIgnore.txt create mode 100644 docs/output/D02InclusionAndFormat.txt create mode 100644 docs/output/D03AliasAndUnknownFields.txt create mode 100644 docs/output/D04CreatorsAndUnwrapping.txt create mode 100644 docs/output/E03SimpleModuleRegistration.txt create mode 100644 docs/output/E04MixinAnnotations.txt create mode 100644 docs/output/E05ValueSerializerDirect.txt create mode 100644 docs/output/F01SerialiseMixedList.txt create mode 100644 docs/output/F02DeserialiseMixedList.txt create mode 100644 docs/output/F03IncludeStrategies.txt create mode 100644 docs/output/F04UnknownTypeId.txt create mode 100644 docs/output/G01StreamingParserFilter.txt create mode 100644 docs/output/G02StreamingGenerator.txt create mode 100644 docs/output/G03TreeModelNavigation.txt create mode 100644 docs/output/G04ThreeApproachesMeasured.txt create mode 100644 docs/output/H01SafePolymorphismByAnnotation.txt create mode 100644 docs/output/H02DefaultTypingRemoved.txt create mode 100644 docs/output/H03PolymorphicTypeValidatorAllowlist.txt create mode 100644 docs/output/H04StreamReadConstraints.txt create mode 100644 docs/output/H05NeverDeserialiseIntoObject.txt create mode 100644 docs/output/Y01UncheckedExceptions.txt create mode 100644 docs/output/Y02TrailingTokens.txt create mode 100644 docs/output/Y03DateTimeDefaults.txt create mode 100644 docs/output/Y04ImmutableMapperAndReaders.txt create mode 100644 docs/output/Y05CreatorDetection.txt create mode 100644 docs/output/Y06RecyclerPoolTuning.txt create mode 100644 docs/part0-setup.md create mode 100644 docs/part1-objectmapper.md create mode 100644 docs/part2-modern-java.md create mode 100644 docs/part3-annotations.md create mode 100644 docs/part4-custom.md create mode 100644 docs/part5-polymorphic.md create mode 100644 docs/part6-streaming.md create mode 100644 docs/part7-security.md create mode 100644 pom.xml create mode 100755 run-all.sh create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java create mode 100644 src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java create mode 100644 src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java create mode 100644 src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java create mode 100644 src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java create mode 100644 src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java create mode 100644 src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java create mode 100644 src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java create mode 100644 src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java create mode 100644 src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java create mode 100644 src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java create mode 100644 src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java create mode 100644 src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java create mode 100644 src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java create mode 100644 src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java create mode 100644 src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java create mode 100644 src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java create mode 100644 src/main/java/com/ankurm/jackson3/part4custom/Money.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/BankTransferPayment.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/CreditCardPayment.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java create mode 100644 src/main/java/com/ankurm/jackson3/part5polymorphic/PaymentMethod.java create mode 100644 src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java create mode 100644 src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java create mode 100644 src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java create mode 100644 src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java create mode 100644 src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java create mode 100644 src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java create mode 100644 src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java create mode 100644 src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java create mode 100644 src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ebf457 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +target/ +*.iml +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..18cc9ee --- /dev/null +++ b/README.md @@ -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)` 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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3c5dc9b --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/docs/beyond.md b/docs/beyond.md new file mode 100644 index 0000000..030ebe8 --- /dev/null +++ b/docs/beyond.md @@ -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. diff --git a/docs/output/A01FirstRoundTrip.txt b/docs/output/A01FirstRoundTrip.txt new file mode 100644 index 0000000..49b96aa --- /dev/null +++ b/docs/output/A01FirstRoundTrip.txt @@ -0,0 +1,2 @@ +{"listPrice":79.99,"productId":1,"productName":"Mechanical Keyboard"} +Mechanical Keyboard diff --git a/docs/output/A02SharedMapperConfiguration.txt b/docs/output/A02SharedMapperConfiguration.txt new file mode 100644 index 0000000..0359196 --- /dev/null +++ b/docs/output/A02SharedMapperConfiguration.txt @@ -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 diff --git a/docs/output/A03ThreeProcessingModels.txt b/docs/output/A03ThreeProcessingModels.txt new file mode 100644 index 0000000..0de7e9d --- /dev/null +++ b/docs/output/A03ThreeProcessingModels.txt @@ -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 diff --git a/docs/output/B01WriteJson.txt b/docs/output/B01WriteJson.txt new file mode 100644 index 0000000..3056755 --- /dev/null +++ b/docs/output/B01WriteJson.txt @@ -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" ] +} diff --git a/docs/output/B02ReadJson.txt b/docs/output/B02ReadJson.txt new file mode 100644 index 0000000..5ce216c --- /dev/null +++ b/docs/output/B02ReadJson.txt @@ -0,0 +1,3 @@ +from String : Jackson Deep Dive +from File : 1 +from Stream : [java, json] diff --git a/docs/output/B03GenericCollections.txt b/docs/output/B03GenericCollections.txt new file mode 100644 index 0000000..d96975b --- /dev/null +++ b/docs/output/B03GenericCollections.txt @@ -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 diff --git a/docs/output/B04PropertyOrdering.txt b/docs/output/B04PropertyOrdering.txt new file mode 100644 index 0000000..3156b51 --- /dev/null +++ b/docs/output/B04PropertyOrdering.txt @@ -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} diff --git a/docs/output/C01RecordRoundTrip.txt b/docs/output/C01RecordRoundTrip.txt new file mode 100644 index 0000000..debf3b3 --- /dev/null +++ b/docs/output/C01RecordRoundTrip.txt @@ -0,0 +1,3 @@ +{"productId":101,"productName":"Wireless Keyboard","unitPrice":49.99} +Wireless Keyboard +round-trip equal: true diff --git a/docs/output/C02OptionalFields.txt b/docs/output/C02OptionalFields.txt new file mode 100644 index 0000000..53653ef --- /dev/null +++ b/docs/output/C02OptionalFields.txt @@ -0,0 +1,5 @@ +present : {"customerName":"Alice","middleName":"Marie"} +empty : {"customerName":"Bob","middleName":null} +absent : {"customerName":"Bob"} +isPresent: true +missing -> Optional.empty (null? false) diff --git a/docs/output/C03SealedWithSubTypes.txt b/docs/output/C03SealedWithSubTypes.txt new file mode 100644 index 0000000..d85fcd8 --- /dev/null +++ b/docs/output/C03SealedWithSubTypes.txt @@ -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}] diff --git a/docs/output/C04SealedAutoDiscovery.txt b/docs/output/C04SealedAutoDiscovery.txt new file mode 100644 index 0000000..919529a --- /dev/null +++ b/docs/output/C04SealedAutoDiscovery.txt @@ -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] diff --git a/docs/output/D01RenameAndIgnore.txt b/docs/output/D01RenameAndIgnore.txt new file mode 100644 index 0000000..2a95c5f --- /dev/null +++ b/docs/output/D01RenameAndIgnore.txt @@ -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 diff --git a/docs/output/D02InclusionAndFormat.txt b/docs/output/D02InclusionAndFormat.txt new file mode 100644 index 0000000..a24b659 --- /dev/null +++ b/docs/output/D02InclusionAndFormat.txt @@ -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"} diff --git a/docs/output/D03AliasAndUnknownFields.txt b/docs/output/D03AliasAndUnknownFields.txt new file mode 100644 index 0000000..190610e --- /dev/null +++ b/docs/output/D03AliasAndUnknownFields.txt @@ -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] diff --git a/docs/output/D04CreatorsAndUnwrapping.txt b/docs/output/D04CreatorsAndUnwrapping.txt new file mode 100644 index 0000000..42640b1 --- /dev/null +++ b/docs/output/D04CreatorsAndUnwrapping.txt @@ -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]} diff --git a/docs/output/E03SimpleModuleRegistration.txt b/docs/output/E03SimpleModuleRegistration.txt new file mode 100644 index 0000000..7c50fd6 --- /dev/null +++ b/docs/output/E03SimpleModuleRegistration.txt @@ -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"} diff --git a/docs/output/E04MixinAnnotations.txt b/docs/output/E04MixinAnnotations.txt new file mode 100644 index 0000000..29163b1 --- /dev/null +++ b/docs/output/E04MixinAnnotations.txt @@ -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"} diff --git a/docs/output/E05ValueSerializerDirect.txt b/docs/output/E05ValueSerializerDirect.txt new file mode 100644 index 0000000..ad8f0b8 --- /dev/null +++ b/docs/output/E05ValueSerializerDirect.txt @@ -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 diff --git a/docs/output/F01SerialiseMixedList.txt b/docs/output/F01SerialiseMixedList.txt new file mode 100644 index 0000000..35f6914 --- /dev/null +++ b/docs/output/F01SerialiseMixedList.txt @@ -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}] diff --git a/docs/output/F02DeserialiseMixedList.txt b/docs/output/F02DeserialiseMixedList.txt new file mode 100644 index 0000000..b6e9d0a --- /dev/null +++ b/docs/output/F02DeserialiseMixedList.txt @@ -0,0 +1,4 @@ +Card ending: 4242 +Bank: National Bank +lossy JSON round-trip -> InvalidTypeIdException +correct JSON round-trip -> 2 payments, CreditCardPayment first diff --git a/docs/output/F03IncludeStrategies.txt b/docs/output/F03IncludeStrategies.txt new file mode 100644 index 0000000..a7ada33 --- /dev/null +++ b/docs/output/F03IncludeStrategies.txt @@ -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] diff --git a/docs/output/F04UnknownTypeId.txt b/docs/output/F04UnknownTypeId.txt new file mode 100644 index 0000000..ce7a780 --- /dev/null +++ b/docs/output/F04UnknownTypeId.txt @@ -0,0 +1,3 @@ +unknown logical name -> InvalidTypeIdException +attacker-supplied class name -> InvalidTypeIdException +missing discriminator -> InvalidTypeIdException diff --git a/docs/output/G01StreamingParserFilter.txt b/docs/output/G01StreamingParserFilter.txt new file mode 100644 index 0000000..39159c3 --- /dev/null +++ b/docs/output/G01StreamingParserFilter.txt @@ -0,0 +1,2 @@ +ERROR: Database connection failed +Total errors found: 1 diff --git a/docs/output/G02StreamingGenerator.txt b/docs/output/G02StreamingGenerator.txt new file mode 100644 index 0000000..2038f4e --- /dev/null +++ b/docs/output/G02StreamingGenerator.txt @@ -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 diff --git a/docs/output/G03TreeModelNavigation.txt b/docs/output/G03TreeModelNavigation.txt new file mode 100644 index 0000000..136f13a --- /dev/null +++ b/docs/output/G03TreeModelNavigation.txt @@ -0,0 +1,8 @@ +Customer: Alice +KB-01 x2 +MS-42 x1 +Has discount: false +path(missing) : (class MissingNode) +get(missing) : null +deep path : '' +treeToValue : CustomerRecord[name=Alice, tier=gold] diff --git a/docs/output/G04ThreeApproachesMeasured.txt b/docs/output/G04ThreeApproachesMeasured.txt new file mode 100644 index 0000000..46bbadb --- /dev/null +++ b/docs/output/G04ThreeApproachesMeasured.txt @@ -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 diff --git a/docs/output/H01SafePolymorphismByAnnotation.txt b/docs/output/H01SafePolymorphismByAnnotation.txt new file mode 100644 index 0000000..d6ff4ab --- /dev/null +++ b/docs/output/H01SafePolymorphismByAnnotation.txt @@ -0,0 +1,3 @@ +email : EmailNotification[a@example.com] +sms : SmsNotification[+441234567890] +attack: rejected with InvalidTypeIdException diff --git a/docs/output/H02DefaultTypingRemoved.txt b/docs/output/H02DefaultTypingRemoved.txt new file mode 100644 index 0000000..150b424 --- /dev/null +++ b/docs/output/H02DefaultTypingRemoved.txt @@ -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 diff --git a/docs/output/H03PolymorphicTypeValidatorAllowlist.txt b/docs/output/H03PolymorphicTypeValidatorAllowlist.txt new file mode 100644 index 0000000..935d377 --- /dev/null +++ b/docs/output/H03PolymorphicTypeValidatorAllowlist.txt @@ -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 diff --git a/docs/output/H04StreamReadConstraints.txt b/docs/output/H04StreamReadConstraints.txt new file mode 100644 index 0000000..935eff8 --- /dev/null +++ b/docs/output/H04StreamReadConstraints.txt @@ -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) diff --git a/docs/output/H05NeverDeserialiseIntoObject.txt b/docs/output/H05NeverDeserialiseIntoObject.txt new file mode 100644 index 0000000..63ad28a --- /dev/null +++ b/docs/output/H05NeverDeserialiseIntoObject.txt @@ -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 diff --git a/docs/output/Y01UncheckedExceptions.txt b/docs/output/Y01UncheckedExceptions.txt new file mode 100644 index 0000000..cb073b8 --- /dev/null +++ b/docs/output/Y01UncheckedExceptions.txt @@ -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"}] diff --git a/docs/output/Y02TrailingTokens.txt b/docs/output/Y02TrailingTokens.txt new file mode 100644 index 0000000..51fe924 --- /dev/null +++ b/docs/output/Y02TrailingTokens.txt @@ -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] diff --git a/docs/output/Y03DateTimeDefaults.txt b/docs/output/Y03DateTimeDefaults.txt new file mode 100644 index 0000000..c2df762 --- /dev/null +++ b/docs/output/Y03DateTimeDefaults.txt @@ -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 diff --git a/docs/output/Y04ImmutableMapperAndReaders.txt b/docs/output/Y04ImmutableMapperAndReaders.txt new file mode 100644 index 0000000..8634489 --- /dev/null +++ b/docs/output/Y04ImmutableMapperAndReaders.txt @@ -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 diff --git a/docs/output/Y05CreatorDetection.txt b/docs/output/Y05CreatorDetection.txt new file mode 100644 index 0000000..ba9d6e9 --- /dev/null +++ b/docs/output/Y05CreatorDetection.txt @@ -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" diff --git a/docs/output/Y06RecyclerPoolTuning.txt b/docs/output/Y06RecyclerPoolTuning.txt new file mode 100644 index 0000000..4147404 --- /dev/null +++ b/docs/output/Y06RecyclerPoolTuning.txt @@ -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 + diff --git a/docs/part0-setup.md b/docs/part0-setup.md new file mode 100644 index 0000000..041ad9a --- /dev/null +++ b/docs/part0-setup.md @@ -0,0 +1,60 @@ +# Part 0 — Jackson 101: setup and the ObjectMapper lifecycle + +Post: + +## 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). diff --git a/docs/part1-objectmapper.md b/docs/part1-objectmapper.md new file mode 100644 index 0000000..111ff8f --- /dev/null +++ b/docs/part1-objectmapper.md @@ -0,0 +1,69 @@ +# Part 1 — ObjectMapper: reading and writing + +Post: + +## 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`. 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. diff --git a/docs/part2-modern-java.md b/docs/part2-modern-java.md new file mode 100644 index 0000000..927bd0a --- /dev/null +++ b/docs/part2-modern-java.md @@ -0,0 +1,70 @@ +# Part 2 — Records, Optionals and sealed types + +Post: + +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` 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. diff --git a/docs/part3-annotations.md b/docs/part3-annotations.md new file mode 100644 index 0000000..b3a652e --- /dev/null +++ b/docs/part3-annotations.md @@ -0,0 +1,74 @@ +# Part 3 — Annotations + +Post: + +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]} +``` + diff --git a/docs/part4-custom.md b/docs/part4-custom.md new file mode 100644 index 0000000..d24b40e --- /dev/null +++ b/docs/part4-custom.md @@ -0,0 +1,72 @@ +# Part 4 — Custom serialisers, deserialisers and mix-ins + +Post: + +This is the part with the most API churn. The post's code is Jackson 2 throughout. + +| Jackson 2 | Jackson 3 | +|---|---| +| `JsonSerializer` | `ValueSerializer` | +| `JsonDeserializer` | `ValueDeserializer` | +| `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 +``` + diff --git a/docs/part5-polymorphic.md b/docs/part5-polymorphic.md new file mode 100644 index 0000000..b4b3a77 --- /dev/null +++ b/docs/part5-polymorphic.md @@ -0,0 +1,111 @@ +# Part 5 — Polymorphic deserialisation + +Post: + +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 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>() {})` — 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. diff --git a/docs/part6-streaming.md b/docs/part6-streaming.md new file mode 100644 index 0000000..bb2af42 --- /dev/null +++ b/docs/part6-streaming.md @@ -0,0 +1,80 @@ +# Part 6 — Streaming API and the tree model + +Post: + +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 : '' +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. diff --git a/docs/part7-security.md b/docs/part7-security.md new file mode 100644 index 0000000..1d7d722 --- /dev/null +++ b/docs/part7-security.md @@ -0,0 +1,116 @@ +# Part 7 — Security + +Post: + +## 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. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2cd0624 --- /dev/null +++ b/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + com.ankurm + jackson3-by-example + 1.0.0 + jar + + jackson3-by-example + Runnable companion code for the Jackson 3 series on ankurm.com + + + + 21 + UTF-8 + 3.2.1 + + + + + + + tools.jackson + jackson-bom + ${jackson.version} + pom + import + + + + + + + + tools.jackson.core + jackson-databind + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + + + diff --git a/run-all.sh b/run-all.sh new file mode 100755 index 0000000..9a76ab8 --- /dev/null +++ b/run-all.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Compiles the project and runs every example, writing each program's real output +# to docs/output/.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 diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java b/src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java new file mode 100644 index 0000000..1fa008d --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y01UncheckedExceptions.java @@ -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 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); + } +} diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java b/src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java new file mode 100644 index 0000000..56cf691 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y02TrailingTokens.java @@ -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(); + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java b/src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java new file mode 100644 index 0000000..dd550c8 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y03DateTimeDefaults.java @@ -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; + } +} diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java b/src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java new file mode 100644 index 0000000..a42de25 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y04ImmutableMapperAndReaders.java @@ -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"; + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java b/src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java new file mode 100644 index 0000000..8612bb0 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y05CreatorDetection.java @@ -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"))); + } +} diff --git a/src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java b/src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java new file mode 100644 index 0000000..898fffc --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/beyond/Y06RecyclerPoolTuning.java @@ -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 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) + .>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); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java b/src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java new file mode 100644 index 0000000..4c03582 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part0setup/A01FirstRoundTrip.java @@ -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. + } +} diff --git a/src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java b/src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java new file mode 100644 index 0000000..734ed24 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part0setup/A02SharedMapperConfiguration.java @@ -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); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java b/src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java new file mode 100644 index 0000000..a132910 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part0setup/A03ThreeProcessingModels.java @@ -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()); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java b/src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java new file mode 100644 index 0000000..b28c1bb --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part1objectmapper/B01WriteJson.java @@ -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 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(); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java b/src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java new file mode 100644 index 0000000..c7f803a --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part1objectmapper/B02ReadJson.java @@ -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 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(); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java b/src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java new file mode 100644 index 0000000..6bc8fe6 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part1objectmapper/B03GenericCollections.java @@ -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
through an anonymous subclass, + // so the parameterised type survives erasure and reaches Jackson at runtime. + List
articles = mapper.readValue(jsonArray, new TypeReference>() { }); + 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 byKey = + mapper.readValue(jsonObject, new TypeReference>() { }); + System.out.println("map value : " + byKey.get("a").title()); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java b/src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java new file mode 100644 index 0000000..101d474 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part1objectmapper/B04PropertyOrdering.java @@ -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))); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java b/src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java new file mode 100644 index 0000000..97ecf1c --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part2modernjava/C01RecordRoundTrip.java @@ -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 . + */ +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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java b/src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java new file mode 100644 index 0000000..d682b29 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part2modernjava/C02OptionalFields.java @@ -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" + * + * 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 middleName) { } + + /** NON_ABSENT is the inclusion value that understands Optional.empty(). */ + @JsonInclude(JsonInclude.Include.NON_ABSENT) + public record CompactProfile(String customerName, Optional 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) + ")"); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java b/src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java new file mode 100644 index 0000000..a0da051 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part2modernjava/C03SealedWithSubTypes.java @@ -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 shapes = mapper.readValue(json, new TypeReference>() { }); + + // 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) 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>() { }) + .writeValueAsString(shapes)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java b/src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java new file mode 100644 index 0000000..675b596 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part2modernjava/C04SealedAutoDiscovery.java @@ -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); + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java b/src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java new file mode 100644 index 0000000..a65bc11 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part3annotations/D01RenameAndIgnore.java @@ -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()); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java b/src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java new file mode 100644 index 0000000..1b8b39b --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part3annotations/D02InclusionAndFormat.java @@ -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 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))); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java b/src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java new file mode 100644 index 0000000..a2c6082 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part3annotations/D03AliasAndUnknownFields.java @@ -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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java b/src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java new file mode 100644 index 0000000..8144d95 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part3annotations/D04CreatorsAndUnwrapping.java @@ -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 extras = new LinkedHashMap<>(); + @JsonAnyGetter public Map 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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java b/src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java new file mode 100644 index 0000000..61f43ea --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/E01MoneyValueSerializer.java @@ -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 { + + 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(); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java b/src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java new file mode 100644 index 0000000..68277f4 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/E02MoneyValueDeserializer.java @@ -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 { + + 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); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java b/src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java new file mode 100644 index 0000000..256d8f2 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/E03SimpleModuleRegistration.java @@ -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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java b/src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java new file mode 100644 index 0000000..ac306a5 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/E04MixinAnnotations.java @@ -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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java b/src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java new file mode 100644 index 0000000..f275487 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/E05ValueSerializerDirect.java @@ -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/JsonDeserializer are gone. The Jackson 3 names + * are ValueSerializer/ValueDeserializer. 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 { + @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()); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part4custom/Money.java b/src/main/java/com/ankurm/jackson3/part4custom/Money.java new file mode 100644 index 0000000..8e960c0 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part4custom/Money.java @@ -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) { } diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/BankTransferPayment.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/BankTransferPayment.java new file mode 100644 index 0000000..f2edb0b --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/BankTransferPayment.java @@ -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; } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/CreditCardPayment.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/CreditCardPayment.java new file mode 100644 index 0000000..d13e1fb --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/CreditCardPayment.java @@ -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; } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java new file mode 100644 index 0000000..b10a5a5 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/F01SerialiseMixedList.java @@ -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 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 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>() { }) + .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]))); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java new file mode 100644 index 0000000..73efed5 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/F02DeserialiseMixedList.java @@ -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 payments = + mapper.readValue(GOOD_JSON, new TypeReference>() { }); + + 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>() { }); + 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>() { }) + .writeValueAsString(F01SerialiseMixedList.samplePayments()); + List again = + mapper.readValue(correct, new TypeReference>() { }); + System.out.println("correct JSON round-trip -> " + again.size() + " payments, " + + again.get(0).getClass().getSimpleName() + " first"); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java new file mode 100644 index 0000000..75025f0 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/F03IncludeStrategies.java @@ -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)); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java new file mode 100644 index 0000000..f37d515 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/F04UnknownTypeId.java @@ -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()); + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/part5polymorphic/PaymentMethod.java b/src/main/java/com/ankurm/jackson3/part5polymorphic/PaymentMethod.java new file mode 100644 index 0000000..3b6f15d --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part5polymorphic/PaymentMethod.java @@ -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; } +} diff --git a/src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java b/src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java new file mode 100644 index 0000000..63759ff --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part6streaming/G01StreamingParserFilter.java @@ -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); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java b/src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java new file mode 100644 index 0000000..9582190 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part6streaming/G02StreamingGenerator.java @@ -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"); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java b/src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java new file mode 100644 index 0000000..3f9c684 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part6streaming/G03TreeModelNavigation.java @@ -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("") + "'"); + + // Switch from tree to data binding at any node. + CustomerRecord customer = mapper.treeToValue(rootNode.path("customer"), CustomerRecord.class); + System.out.println("treeToValue : " + customer); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java b/src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java new file mode 100644 index 0000000..56792ad --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part6streaming/G04ThreeApproachesMeasured.java @@ -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 all = mapper.readValue(file, new TypeReference>() { }); + 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; + } +} diff --git a/src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java b/src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java new file mode 100644 index 0000000..b820b9f --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H01SafePolymorphismByAnnotation.java @@ -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()); + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java b/src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java new file mode 100644 index 0000000..502102b --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H02DefaultTypingRemoved.java @@ -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"); + } +} diff --git a/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java b/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java new file mode 100644 index 0000000..f2a88d8 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H03PolymorphicTypeValidatorAllowlist.java @@ -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()); + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java b/src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java new file mode 100644 index 0000000..58615e4 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H04StreamReadConstraints.java @@ -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() + ")"; + } + } +} diff --git a/src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java b/src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java new file mode 100644 index 0000000..6939cb0 --- /dev/null +++ b/src/main/java/com/ankurm/jackson3/part7security/H05NeverDeserialiseIntoObject.java @@ -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()); + } + } +}