Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ A Java port of the `SealedPolymorphismSupport` added to jackson-module-scala in
using the same `@type` property and the same name-derivation rules. Enums are the one place the two
diverge: Scala tags a `case object`, whereas this module leaves a Java enum to Jackson as a string.

> **Status: early.** Covered by 76 tests, including ports of the Scala module's
> **Status: early.** Covered by 95 tests, including ports of the Scala module's
> `SealedPolymorphismSpec` and `NestedPolymorphismSpec`, so the examples below are verified output.
> Snapshots are published, but there is no release yet and the API may still change.

Expand Down Expand Up @@ -74,8 +74,42 @@ mapper.readValue("{\"@type\":\"Dog\",\"name\":\"rex\"}", Animal.class);
// Dog[name=rex]
```

The module only ever looks at types carrying the marker, so registering it has no effect on
anything else your application serializes.
The module only ever looks at types that have opted in — through the marker, or through
[registration](#hierarchies-you-cannot-change) — so adding it has no effect on anything else your
application serializes.

## Hierarchies you cannot change

Extending the marker means editing the base type. Where that is not possible — a hierarchy from a
library, or generated code — register the hierarchy's root with the module instead:

```java
SealedPolymorphismModule module = new SealedPolymorphismModule()
.registerSealedInterfaceOrClass(Animal.class)
.registerSealedInterfaceOrClass(Shape.class);

ObjectMapper mapper = JsonMapper.builder().addModule(module).build();
```

Call `registerSealedInterfaceOrClass` once per hierarchy root, as often as needed. Registering adds
to what the module already handles through the marker rather than replacing it, so marked and
registered hierarchies work through the same mapper.

A registered hierarchy is handled exactly as a marked one: same `@type` names, same reading, same
requirement that it be sealed. Register the *root* — its implementations follow from the `permits`
clause and do not need registering themselves. Registering a type part way down a hierarchy is
allowed and makes that type the root, so names are derived relative to it and its siblings are left
alone.

`registerSealedInterfaceOrClass` rejects anything it cannot handle there and then, rather than
later when Jackson first meets the type:

| Argument | Result |
| --- | --- |
| `null` | error |
| not `sealed` | error — registration replaces the marker, not the closed-hierarchy requirement |
| an `enum` | error — enums are left to Jackson; register the sealed interface it implements instead |
| carries `@JsonTypeInfo` | error — that already tells Jackson how to write and read the hierarchy |

## How names are derived

Expand Down Expand Up @@ -208,12 +242,13 @@ performance but not behaviour.

## Tests

76 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:
95 tests, in `src/test/java/com/github/pjfanning/jackson/sealed/`:

| Test | Covers |
| --- | --- |
| `poly/SealedPolymorphismTest` | Ported from the Scala `SealedPolymorphismSpec` |
| `poly/EnumsUntouchedTest` | That enums serialize identically with and without this module |
| `poly/RegisteredTypeTest` | Hierarchies opted in by registration rather than by the marker |
| `poly/NestedPolymorphismTest` | Ported from the Scala `NestedPolymorphismSpec` — a polymorphic value holding a polymorphic value |
| `poly/InvalidHierarchyTest` | The three ways a hierarchy can fail to be closed — not sealed, reopened by a `non-sealed` member, clashing derived names — on both the read and the write path, plus that a marked enum is ignored |
| `SealedTypesTest` | The name derivation itself, and resolution |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,25 @@ final class SealedPolymorphicDeserializer extends StdDeserializer<Object> {
private static final long serialVersionUID = 1L;

private final Class<?> baseClass;
private final SealedHierarchy hierarchy;

SealedPolymorphicDeserializer(Class<?> baseClass) {
SealedPolymorphicDeserializer(Class<?> baseClass, SealedHierarchy hierarchy) {
super(baseClass);
this.baseClass = baseClass;
this.hierarchy = hierarchy;
}

@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) {
if (p.currentToken() != JsonToken.START_OBJECT) {
String hint = SealedTypes.hierarchyOf(baseClass).enumMemberHint();
String hint = hierarchy.enumMemberHint();
return ctxt.reportInputMismatch(baseClass, "Expected a JSON object with a %s property to create %s.%s",
SealedTypes.TYPE_PROPERTY_NAME, baseClass.getName(), hint == null ? "" : hint);
}
TaggedObject tagged = TaggedObject.split(p, ctxt);
Class<?> subtype = SealedTypes.hierarchyOf(baseClass).resolve(baseClass, tagged.typeName());
Class<?> subtype = hierarchy.resolve(baseClass, tagged.typeName());
if (subtype == null) {
return TaggedObject.unresolved(ctxt, baseClass, tagged.typeName());
return TaggedObject.unresolved(ctxt, baseClass, tagged.typeName(), hierarchy);
}
return ctxt.readValue(tagged.parser(), subtype);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,21 @@ final class SealedPolymorphismDeserializerModifier extends ValueDeserializerModi

private static final long serialVersionUID = 1L;

private final transient SealedTypes types;

SealedPolymorphismDeserializerModifier(SealedTypes types) {
this.types = types;
}

@Override
public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanDescription.Supplier beanDescRef,
BeanDeserializerBuilder builder) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.isMarked(rawClass)) {
if (!types.isOptedIn(rawClass)) {
return builder;
}
SealedTypes.checkNoConflictingJsonTypeInfo(rawClass);
if (SealedTypes.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) {
types.checkNoConflictingJsonTypeInfo(rawClass);
if (types.isSupported(rawClass) && SealedTypes.isConcrete(rawClass)) {
builder.addIgnorable(SealedTypes.TYPE_PROPERTY_NAME);
}
return builder;
Expand All @@ -33,12 +39,12 @@ public BeanDeserializerBuilder updateBuilder(DeserializationConfig config, BeanD
public ValueDeserializer<?> modifyDeserializer(DeserializationConfig config, BeanDescription.Supplier beanDescRef,
ValueDeserializer<?> deserializer) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.needsSubtypeDispatch(rawClass)) {
if (!types.needsSubtypeDispatch(rawClass)) {
return deserializer;
}
@SuppressWarnings("unchecked")
ValueDeserializer<Object> delegate = (ValueDeserializer<Object>) deserializer;
return new TaggedBeanDeserializer(rawClass, delegate);
return new TaggedBeanDeserializer(rawClass, types.hierarchyOf(rawClass), delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,23 @@
/** Binds the dispatching deserializer to every base type of a marked hierarchy. */
final class SealedPolymorphismDeserializers extends Deserializers.Base {

private final SealedTypes types;

SealedPolymorphismDeserializers(SealedTypes types) {
this.types = types;
}

@Override
public ValueDeserializer<?> findBeanDeserializer(JavaType type, DeserializationConfig config,
BeanDescription.Supplier beanDescRef) {
Class<?> rawClass = type.getRawClass();
return hasDeserializerFor(config, rawClass) ? new SealedPolymorphicDeserializer(rawClass) : null;
return hasDeserializerFor(config, rawClass)
? new SealedPolymorphicDeserializer(rawClass, types.hierarchyOf(rawClass))
: null;
}

@Override
public boolean hasDeserializerFor(DeserializationConfig config, Class<?> valueType) {
return SealedTypes.isBaseType(valueType);
return types.isBaseType(valueType);
}
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,88 @@
package com.github.pjfanning.jackson.sealed;

import java.util.Set;

import tools.jackson.core.Version;
import tools.jackson.databind.JacksonModule;

/**
* Jackson module that gives automatic polymorphic serialization to {@code sealed} hierarchies
* marked with {@link SealedPolymorphismSupport}.
* Jackson module that gives automatic polymorphic serialization to {@code sealed} hierarchies.
*
* <p>A hierarchy opts in either by extending {@link SealedPolymorphismSupport}:
*
* <pre>{@code
* ObjectMapper mapper = JsonMapper.builder()
* .addModule(new SealedPolymorphismModule())
* .build();
* }</pre>
*
* <p>The module only ever looks at types carrying the marker, so registering it has no effect on
* anything else an application serializes.
* <p>or by being registered here, for a hierarchy whose source cannot be changed to extend the
* marker - one from a library, or generated code. A module handles both at once: registering a type
* adds to the hierarchies it already picks up from the marker, it does not replace them.
*
* <pre>{@code
* SealedPolymorphismModule module = new SealedPolymorphismModule()
* .registerSealedInterfaceOrClass(Animal.class)
* .registerSealedInterfaceOrClass(Shape.class);
*
* ObjectMapper mapper = JsonMapper.builder().addModule(module).build();
* }</pre>
*
* <p>A registered type is handled exactly as if it carried the marker, and is held to the same
* requirement: it must be {@code sealed}, and so must every type below it that is not {@code final}.
* Anything that cannot be handled is rejected by
* {@link #registerSealedInterfaceOrClass(Class) registerSealedInterfaceOrClass} itself, rather than
* later when Jackson first meets the type.
*
* <p>Register the <em>root</em> of a hierarchy; its implementations follow from the root's
* {@code permits} clause and do not need registering themselves. Registering a type part way down a
* hierarchy is allowed, and makes that type the root - names are then derived relative to it, and
* its siblings are not handled.
*
* <p>The module only ever looks at types that have opted in one of these two ways, so registering
* it has no effect on anything else an application serializes.
*
* @see SealedPolymorphismSupport
*/
public class SealedPolymorphismModule extends JacksonModule {

private final SealedTypes types = new SealedTypes();

/**
* A module handling every hierarchy that extends {@link SealedPolymorphismSupport}. Add
* hierarchies that do not with
* {@link #registerSealedInterfaceOrClass(Class) registerSealedInterfaceOrClass}.
*/
public SealedPolymorphismModule() {
}

/**
* Handles the given sealed interface or class as if it carried
* {@link SealedPolymorphismSupport}, on top of everything that does carry it.
*
* <p>Call it once per hierarchy root, as often as needed. Register the root: its
* implementations follow from the root's {@code permits} clause and do not need registering
* themselves. Registering the same type twice is harmless.
*
* <p>Registering a type part way down a hierarchy is allowed, and makes that type the root -
* names are then derived relative to it, and its siblings are not handled.
*
* @param sealedType root of a sealed hierarchy to handle
* @return this module, so calls can be chained
* @throws IllegalArgumentException if the type is null, is not {@code sealed}, is an enum, or
* carries {@code @JsonTypeInfo} - which already tells Jackson
* how to write and read the hierarchy
*/
public SealedPolymorphismModule registerSealedInterfaceOrClass(Class<?> sealedType) {
types.register(sealedType);
return this;
}

/** The hierarchy roots registered with this module, beyond those carrying the marker. */
public Set<Class<?>> registeredTypes() {
return types.registeredRoots();
}

@Override
public String getModuleName() {
return "SealedPolymorphismModule";
Expand All @@ -30,11 +93,21 @@ public Version version() {
return PackageVersion.VERSION;
}

/**
* Two modules registering different types are different modules. Jackson drops a module whose
* registration id it has already seen, so without this a second module carrying extra
* registrations would be silently ignored.
*/
@Override
public Object getRegistrationId() {
return getClass().getName() + types.registeredRoots();
}

@Override
public void setupModule(SetupContext context) {
context.addSerializerModifier(new SealedPolymorphismSerializerModifier());
context.addDeserializers(new SealedPolymorphismDeserializers());
context.addDeserializerModifier(new SealedPolymorphismDeserializerModifier());
context.addSerializerModifier(new SealedPolymorphismSerializerModifier(types));
context.addDeserializers(new SealedPolymorphismDeserializers(types));
context.addDeserializerModifier(new SealedPolymorphismDeserializerModifier(types));
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,27 @@ final class SealedPolymorphismSerializerModifier extends ValueSerializerModifier

private static final long serialVersionUID = 1L;

private final transient SealedTypes types;

SealedPolymorphismSerializerModifier(SealedTypes types) {
this.types = types;
}

@Override
public ValueSerializer<?> modifySerializer(SerializationConfig config, BeanDescription.Supplier beanDescRef,
ValueSerializer<?> serializer) {
Class<?> rawClass = beanDescRef.getBeanClass();
if (!SealedTypes.isMarked(rawClass)) {
if (!types.isOptedIn(rawClass)) {
return serializer;
}
SealedTypes.checkNoConflictingJsonTypeInfo(rawClass);
types.checkNoConflictingJsonTypeInfo(rawClass);
// a base type is never written directly - only the implementation dispatched to at runtime
if (!SealedTypes.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) {
if (!types.isSupported(rawClass) || !SealedTypes.isConcrete(rawClass)) {
return serializer;
}
@SuppressWarnings("unchecked")
ValueSerializer<Object> delegate = (ValueSerializer<Object>) serializer;
return new TypeTaggedSerializer(SealedTypes.hierarchyOf(rawClass).nameOf(rawClass), delegate);
return new TypeTaggedSerializer(types.hierarchyOf(rawClass).nameOf(rawClass), delegate);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,25 @@
* doing so, so an enum member carries no {@code @type} name - which means a value of one cannot be
* read back through the hierarchy's base type, though it reads normally where the property is
* declared as the enum type itself. For a stateless member that does round trip through the base,
* use a record with no components. Putting this marker on an enum has no effect.
* use a record with no components.
*
* <h2>Hierarchies you cannot change</h2>
*
* <p>A hierarchy whose source you do not control - from a library, or generated - can be opted in by
* registering it with the module instead of extending this interface:
*
* <pre>{@code
* new SealedPolymorphismModule()
* .registerSealedInterfaceOrClass(Animal.class)
* .registerSealedInterfaceOrClass(Shape.class)
* }</pre>
*
* <p>A registered hierarchy is handled identically to a marked one, and is held to the same
* requirement that it be sealed. See {@link SealedPolymorphismModule} for the details.
*
* <h2>Deferring to Jackson</h2>
*
* <p>{@code @JsonTypeInfo} on the base of a marked hierarchy switches this module off for that
* <p>{@code @JsonTypeInfo} on the base of a handled hierarchy switches this module off for that
* hierarchy, leaving Jackson's own polymorphic handling in sole charge. The same annotation on an
* implementation rather than on the base is a configuration error: Jackson would treat the
* annotated class as a polymorphic base in its own right and demand a type id that nothing in a
Expand Down
Loading