part5polymorphic: add F05-F08 (default typing modes, type validation, sealed hierarchies, unknown subtypes) for the rewritten polymorphism post (#59)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JXVi2GMQ7bR5EmbUFdDj7N
This commit is contained in:
Claude
2026-10-09 10:35:38 +00:00
parent 84070a8dfb
commit b1663c235d
9 changed files with 277 additions and 3 deletions
@@ -0,0 +1,59 @@
package com.ankurm.jackson3.part5polymorphic;
import tools.jackson.databind.DefaultTyping;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
import java.util.List;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "Default typing: what Jackson 3 changed"
*
* Default typing writes a type id for every value whose declared type matches a rule,
* with no annotation on the class. It is the mechanism the security post warns about.
* This prints what each Jackson 3 DefaultTyping constant puts on the wire for the same
* object, so the choice is made by looking rather than by name.
*
* Jackson 3 has six constants (javap on 3.2.3): JAVA_LANG_OBJECT, OBJECT_AND_NON_CONCRETE,
* NON_CONCRETE_AND_ARRAYS, NON_FINAL, NON_FINAL_AND_ENUMS, NON_FINAL_AND_RECORDS.
* The enum is top-level (tools.jackson.databind.DefaultTyping), not nested in ObjectMapper,
* and it is passed to the mapper BUILDER: a built mapper has no activateDefaultTyping.
*/
public class F05DefaultTypingModes {
public interface Shape { }
public record Dot(int x) implements Shape { }
public enum Colour { RED }
public static final class Tag { public String label = "t"; }
/** One field per kind of declared type the constants distinguish. */
public static class Holder {
public Object any = new Tag();
public Shape shape = new Dot(1);
public Dot dot = new Dot(2);
public Tag tag = new Tag();
public Colour colour = Colour.RED;
public String text = "s";
public int[] numbers = {1, 2};
public List<Object> items = List.of("a");
}
public static void main(String[] args) {
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.ankurm.")
.allowIfSubType("java.")
.allowIfSubTypeIsArray()
.build();
System.out.println("NONE (no default typing): " + JsonMapper.builder().build().writeValueAsString(new Holder()));
for (DefaultTyping mode : DefaultTyping.values()) {
JsonMapper mapper = JsonMapper.builder().activateDefaultTyping(ptv, mode).build();
System.out.println();
System.out.println(mode + ":");
System.out.println(" " + mapper.writeValueAsString(new Holder())
.replace("com.ankurm.jackson3.part5polymorphic.F05DefaultTypingModes$", "~"));
}
}
}
@@ -0,0 +1,58 @@
package com.ankurm.jackson3.part5polymorphic;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "Letting the JSON name the class (and why you should not)"
*
* @JsonTypeInfo(use = CLASS) lets the JSON choose any subclass by its fully qualified
* name. Two Jackson 3 controls decide which names are believed:
* - a PolymorphicTypeValidator on the mapper builder (an allow-list of types), and
* - DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED (new in Jackson 3),
* which requires the class to be listed in @JsonSubTypes.
* Every case below reads a payload naming Rogue, a legitimate Plugin subtype that the
* application never meant to accept from the wire. Nothing dangerous is instantiated;
* Rogue only has a field, which is exactly the point: the check is on the NAME.
*/
public class F06TypeValidation {
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, property = "@class")
@com.fasterxml.jackson.annotation.JsonSubTypes(@com.fasterxml.jackson.annotation.JsonSubTypes.Type(Good.class))
public abstract static class Plugin { }
public static class Good extends Plugin { public String note = "ok"; }
public static class Rogue extends Plugin { public String note = "rogue"; }
private static final String GOOD = "{\"@class\":\"" + Good.class.getName() + "\",\"note\":\"x\"}";
private static final String ROGUE = "{\"@class\":\"" + Rogue.class.getName() + "\",\"note\":\"x\"}";
static void read(String label, JsonMapper mapper, String json) {
try {
Plugin p = mapper.readValue(json, Plugin.class);
System.out.printf("%-58s -> accepted %s%n", label, p.getClass().getSimpleName());
} catch (Exception e) {
System.out.printf("%-58s -> %s%n", label, e.getClass().getSimpleName());
}
}
public static void main(String[] args) {
JsonMapper plain = JsonMapper.builder().build();
read("default mapper, Good (listed in @JsonSubTypes)", plain, GOOD);
read("default mapper, Rogue (not listed)", plain, ROGUE);
JsonMapper strict = JsonMapper.builder()
.enable(DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED).build();
read("FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED, Good", strict, GOOD);
read("FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED, Rogue", strict, ROGUE);
JsonMapper allowList = JsonMapper.builder()
.polymorphicTypeValidator(BasicPolymorphicTypeValidator.builder()
.allowIfSubType(Good.class).build())
.build();
read("PolymorphicTypeValidator allowing only Good, Good", allowList, GOOD);
read("PolymorphicTypeValidator allowing only Good, Rogue", allowList, ROGUE);
}
}
@@ -0,0 +1,57 @@
package com.ankurm.jackson3.part5polymorphic;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
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: "Sealed types: let `permits` be the registry"
*
* Builds on part2modernjava/C03 and C04. New here: a NESTED sealed hierarchy, a
* permitted type with no @JsonTypeName, and an exhaustive switch over the result.
* No @JsonSubTypes anywhere.
*/
public class F07SealedHierarchies {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
public sealed interface Event permits Payment, Refund, Audit { }
@JsonTypeName("payment") public record Payment(String id, double amount) implements Event { }
@JsonTypeName("refund") public record Refund(String id, double amount) implements Event { }
/** A sealed sub-hierarchy: its permitted records are discovered through Event too. */
public sealed interface Audit extends Event permits Login, Logout { }
@JsonTypeName("login") public record Login(String user) implements Audit { }
/** Deliberately NO @JsonTypeName: the id falls back to the simple class name. */
public record Logout(String user) implements Audit { }
static String describe(Event e) {
return switch (e) {
case Payment p -> "payment " + p.amount();
case Refund r -> "refund " + r.amount();
case Login l -> "login " + l.user();
case Logout l -> "logout " + l.user();
};
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
List<Event> events = List.of(new Payment("p1", 10.0), new Refund("r1", 4.0),
new Login("ann"), new Logout("ann"));
System.out.println("each as Event.class:");
for (Event e : events) {
System.out.println(" " + mapper.writeValueAsString(e));
}
String json = mapper.writerFor(new TypeReference<List<Event>>() { }).writeValueAsString(events);
List<Event> back = mapper.readValue(json, new TypeReference<List<Event>>() { });
System.out.println("round trip equal: " + events.equals(back));
back.forEach(e -> System.out.println(" " + describe(e)));
System.out.println("nested sealed type read as Audit.class: "
+ mapper.readValue("{\"kind\":\"login\",\"user\":\"bo\"}", Audit.class));
}
}
@@ -0,0 +1,57 @@
package com.ankurm.jackson3.part5polymorphic;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.json.JsonMapper;
/**
* Post: Polymorphic Deserialisation — https://ankurm.com/jackson-polymorphic-deserialization/
* Section: "When a new producer sends a type you have not heard of"
*
* Extends F04 (which shows the default failure). Two ways to be tolerant of a newer
* producer, and what each costs. A tolerant reader must still never turn an unknown
* name into a class lookup; both options below keep the registry closed.
*/
public class F08UnknownSubtypes {
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type", defaultImpl = Unknown.class)
@JsonSubTypes({
@JsonSubTypes.Type(value = Click.class, name = "click"),
@JsonSubTypes.Type(value = Unknown.class, name = "unknown")})
public interface UiEvent { }
public record Click(int x) implements UiEvent { }
/** The catch-all: carries nothing, so nothing from the payload is trusted. */
public record Unknown() implements UiEvent { }
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes(@JsonSubTypes.Type(value = Scroll.class, name = "scroll"))
public interface OtherEvent { }
public record Scroll(int dy) implements OtherEvent { }
static void show(String label, Object result) {
System.out.printf("%-52s -> %s%n", label, result);
}
public static void main(String[] args) {
JsonMapper mapper = JsonMapper.builder().build();
show("defaultImpl, known name", mapper.readValue("{\"type\":\"click\",\"x\":3}", UiEvent.class));
show("defaultImpl, unknown name 'swipe'", mapper.readValue("{\"type\":\"swipe\",\"dx\":9}", UiEvent.class));
show("defaultImpl, class-name attack", mapper.readValue("{\"type\":\"com.malicious.Gadget\"}", UiEvent.class));
show("defaultImpl, discriminator missing", mapper.readValue("{\"x\":3}", UiEvent.class));
JsonMapper lenient = JsonMapper.builder().disable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE).build();
show("FAIL_ON_INVALID_SUBTYPE off, unknown name", lenient.readValue("{\"type\":\"pinch\"}", OtherEvent.class));
try {
show("FAIL_ON_INVALID_SUBTYPE off, missing discriminator",
lenient.readValue("{\"dy\":1}", OtherEvent.class));
} catch (Exception e) {
show("FAIL_ON_INVALID_SUBTYPE off, missing discriminator", e.getClass().getSimpleName());
}
try {
mapper.readValue("{\"type\":\"pinch\"}", OtherEvent.class);
} catch (Exception e) {
show("default, unknown name", e.getClass().getSimpleName());
}
}
}