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:
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user