Skip to content

Add DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED #5027

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 19 commits into from
Mar 31, 2025
Merged
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
2 changes: 2 additions & 0 deletions release-notes/VERSION-2.x
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Project: jackson-databind
failure of `java.util.Optional` (de)serialization without Java 8 module
#5014: Add `java.lang.Runnable` as unsafe base type in `DefaultBaseTypeLimitingValidator`
#5020: Support new `@JsonProperty.isRequired` for overridable definition of "required-ness"
#5027: Add `DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED`
(contributed by @pjfanning)
#5052: Minor bug in `FirstCharBasedValidator.forFirstNameRule()`: returns `null`
in non-default case

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ public enum DeserializationFeature implements ConfigFeature
FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY(true),

/**
* Feature that determines behaviour for data-binding after binding the root value.
* Feature that determines behavior for data-binding after binding the root value.
* If feature is enabled, one more call to
* {@link com.fasterxml.jackson.core.JsonParser#nextToken} is made to ensure that
* no more tokens are found (and if any is found,
Expand All @@ -272,6 +272,24 @@ public enum DeserializationFeature implements ConfigFeature
*/
FAIL_ON_TRAILING_TOKENS(false),

/**
* Feature that determines behavior when deserializing polymorphic types that use
* Class-based Type Id mechanism (either
* {@code JsonTypeInfo.Id.CLASS} or {@code JsonTypeInfo.Id.MINIMAL_CLASS}):
* If enabled, an exception will be
* thrown if a subtype (Class) is encountered that has not been explicitly registered (by
* calling {@link ObjectMapper#registerSubtypes} or
* {@link com.fasterxml.jackson.annotation.JsonSubTypes}).
*<p>
* Note that for Type Name - based Type Id mechanism ({@code JsonTypeInfo.Id.NAME})
* you already need to register the subtypes but with so this feature has no effect.
*<p>
* Feature is disabled by default.
*
* @since 2.19
*/
FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED(false),

/**
* Feature that determines whether Jackson code should catch
* and wrap {@link Exception}s (but never {@link Error}s!)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.jsontype.NamedType;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import com.fasterxml.jackson.databind.type.TypeFactory;
import com.fasterxml.jackson.databind.util.ClassUtil;
Expand All @@ -26,6 +27,11 @@ public class ClassNameIdResolver

protected final PolymorphicTypeValidator _subTypeValidator;

/**
* @since 2.19 (to support {@code DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED})
*/
protected final Set<String> _allowedSubtypes;

/**
* @deprecated Since 2.10 use variant that takes {@link PolymorphicTypeValidator}
*/
Expand All @@ -36,21 +42,56 @@ protected ClassNameIdResolver(JavaType baseType, TypeFactory typeFactory) {

/**
* @since 2.10
* @deprecated Since 2.19 use variant that takes {@code Collection<NamedType>}
*/
@Deprecated
public ClassNameIdResolver(JavaType baseType, TypeFactory typeFactory,
PolymorphicTypeValidator ptv) {
this(baseType, typeFactory, null, ptv);
}

/**
* @since 2.19
*/
public ClassNameIdResolver(JavaType baseType, TypeFactory typeFactory,
Collection<NamedType> subtypes, PolymorphicTypeValidator ptv) {
super(baseType, typeFactory);
_subTypeValidator = ptv;
Set<String> allowedSubtypes = null;
if (subtypes != null) {
for (NamedType t : subtypes) {
if (allowedSubtypes == null) {
allowedSubtypes = new HashSet<>();
}
allowedSubtypes.add(t.getType().getName());
}
}
_allowedSubtypes = (allowedSubtypes == null) ? Collections.emptySet() : allowedSubtypes;
}

/**
* @deprecated since 2.19
*/
@Deprecated
public static ClassNameIdResolver construct(JavaType baseType,
MapperConfig<?> config, PolymorphicTypeValidator ptv) {
return new ClassNameIdResolver(baseType, config.getTypeFactory(), ptv);
}

/**
* @since 2.19
*/
public static ClassNameIdResolver construct(JavaType baseType, MapperConfig<?> config,
Collection<NamedType> subtypes,
PolymorphicTypeValidator ptv) {
return new ClassNameIdResolver(baseType, config.getTypeFactory(), ptv);
return new ClassNameIdResolver(baseType, config.getTypeFactory(), subtypes, ptv);
}

@Override
public JsonTypeInfo.Id getMechanism() { return JsonTypeInfo.Id.CLASS; }

// 28-Mar-2025, tatu: Why is this here; not overridden so... ?
@Deprecated // since 2.19
public void registerSubtype(Class<?> type, String name) {
// not used with class name - based resolvers
}
Expand All @@ -72,14 +113,21 @@ public JavaType typeFromId(DatabindContext context, String id) throws IOExceptio

protected JavaType _typeFromId(String id, DatabindContext ctxt) throws IOException
{
// 24-Apr-2019, tatu: [databind#2195] validate as well as resolve:
JavaType t = ctxt.resolveAndValidateSubType(_baseType, id, _subTypeValidator);
if (t == null) {
if (ctxt instanceof DeserializationContext) {
// First: we may have problem handlers that can deal with it?
return ((DeserializationContext) ctxt).handleUnknownTypeId(_baseType, id, this, "no such class found");
DeserializationContext deserializationContext = null;
if (ctxt instanceof DeserializationContext) {
deserializationContext = (DeserializationContext) ctxt;
}
if ((_allowedSubtypes != null) && (deserializationContext != null)
&& deserializationContext.isEnabled(
DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED)) {
if (!_allowedSubtypes.contains(id)) {
throw deserializationContext.invalidTypeIdException(_baseType, id,
"`DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED` is enabled and the input class is not registered using `@JsonSubTypes` annotation");
}
// ... meaning that we really should never get here.
}
final JavaType t = ctxt.resolveAndValidateSubType(_baseType, id, _subTypeValidator);
if (t == null && deserializationContext != null) {
return deserializationContext.handleUnknownTypeId(_baseType, id, this, "no such class found");
}
return t;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.fasterxml.jackson.databind.jsontype.impl;

import java.io.IOException;
import java.util.Collection;

import com.fasterxml.jackson.annotation.JsonTypeInfo;

import com.fasterxml.jackson.databind.DatabindContext;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.jsontype.NamedType;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import com.fasterxml.jackson.databind.type.TypeFactory;

Expand All @@ -32,10 +34,24 @@ public class MinimalClassNameIdResolver
*/
protected final String _basePackagePrefix;

/**
* @deprecated since 2.19
*/
@Deprecated
protected MinimalClassNameIdResolver(JavaType baseType, TypeFactory typeFactory,
PolymorphicTypeValidator ptv)
{
this(baseType, typeFactory, null, ptv);
}

/**
* @since 2.19
*/
protected MinimalClassNameIdResolver(JavaType baseType, TypeFactory typeFactory,
Collection<NamedType> subtypes,
PolymorphicTypeValidator ptv)
{
super(baseType, typeFactory, ptv);
super(baseType, typeFactory, subtypes, ptv);
String base = baseType.getRawClass().getName();
int ix = base.lastIndexOf('.');
if (ix < 0) { // can this ever occur?
Expand All @@ -47,11 +63,24 @@ protected MinimalClassNameIdResolver(JavaType baseType, TypeFactory typeFactory,
}
}

/**
* @deprecated since 2.19
*/
@Deprecated
public static MinimalClassNameIdResolver construct(JavaType baseType, MapperConfig<?> config,
PolymorphicTypeValidator ptv) {
return new MinimalClassNameIdResolver(baseType, config.getTypeFactory(), ptv);
}

/**
* @since 2.19
*/
public static MinimalClassNameIdResolver construct(JavaType baseType, MapperConfig<?> config,
Collection<NamedType> subtypes,
PolymorphicTypeValidator ptv) {
return new MinimalClassNameIdResolver(baseType, config.getTypeFactory(), subtypes, ptv);
}

@Override
public JsonTypeInfo.Id getMechanism() { return JsonTypeInfo.Id.MINIMAL_CLASS; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,9 @@ protected TypeIdResolver idResolver(MapperConfig<?> config,
switch (_idType) {
case DEDUCTION: // Deduction produces class names to be resolved
case CLASS:
return ClassNameIdResolver.construct(baseType, config, subtypeValidator);
return ClassNameIdResolver.construct(baseType, config, subtypes, subtypeValidator);
case MINIMAL_CLASS:
return MinimalClassNameIdResolver.construct(baseType, config, subtypeValidator);
return MinimalClassNameIdResolver.construct(baseType, config, subtypes, subtypeValidator);
case SIMPLE_NAME:
return SimpleNameIdResolver.construct(config, baseType, subtypes, forSer, forDeser);
case NAME:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.fasterxml.jackson.databind.jsontype;

import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

// For [databind#5027]
public class RegisteredClassDeser5027Test extends DatabindTestUtil
{
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonSubTypes({@JsonSubTypes.Type(value = FooClassImpl.class)})
static abstract class FooClass { }
static class FooClassImpl extends FooClass { }
static class FooClassImpl2 extends FooClass { }

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
static abstract class FooClassNoRegSubTypes { }
static class FooClassNoRegSubTypesImpl extends FooClassNoRegSubTypes { }

@JsonTypeInfo(use = JsonTypeInfo.Id.MINIMAL_CLASS)
@JsonSubTypes({@JsonSubTypes.Type(value = FooMinClassImpl.class)})
static abstract class FooMinClass { }
static class FooMinClassImpl extends FooMinClass { }
static class FooMinClassImpl2 extends FooMinClass { }

/*
/************************************************************
/* Unit tests, valid
/************************************************************
*/

private final ObjectMapper MAPPER = jsonMapperBuilder()
.enable(DeserializationFeature.FAIL_ON_SUBTYPE_CLASS_NOT_REGISTERED)
.build();

@Test
public void testDeserializationIdClass() throws Exception
{
//trying to test if JsonSubTypes enforced
final String foo1 = MAPPER.writeValueAsString(new FooClassImpl());
final String foo2 = MAPPER.writeValueAsString(new FooClassImpl2());
FooClass res1 = MAPPER.readValue(foo1, FooClass.class);
assertTrue(res1 instanceof FooClassImpl);
// next bit should fail because FooClassImpl2 is not listed as a subtype (see mapper config)
assertThrows(InvalidTypeIdException.class, () -> MAPPER.readValue(foo2, FooClass.class));
}

@Test
public void testDeserializationIdClassNoReg() throws Exception
{
final ObjectMapper mapper = newJsonMapper();
final String foo1 = mapper.writeValueAsString(new FooClassNoRegSubTypesImpl());
// the default mapper should be able to deserialize the object (sub type check not enforced)
FooClassNoRegSubTypes res1 = mapper.readValue(foo1, FooClassNoRegSubTypes.class);
assertTrue(res1 instanceof FooClassNoRegSubTypesImpl);
}

@Test
public void testDefaultDeserializationIdClassNoReg() throws Exception
{
//trying to test if JsonSubTypes enforced
final String foo1 = MAPPER.writeValueAsString(new FooClassNoRegSubTypesImpl());
// next bit should fail because FooClassImpl2 is not listed as a subtype (see mapper config)
assertThrows(InvalidTypeIdException.class, () -> MAPPER.readValue(foo1, FooClassNoRegSubTypes.class));
}

@Test
public void testDeserializationIdMinimalClass() throws Exception
{
//trying to test if JsonSubTypes enforced
final String foo1 = MAPPER.writeValueAsString(new FooMinClassImpl());
final String foo2 = MAPPER.writeValueAsString(new FooMinClassImpl2());
FooMinClass res1 = MAPPER.readValue(foo1, FooMinClass.class);
assertTrue(res1 instanceof FooMinClassImpl);
// next bit should fail because FooMinClassImpl2 is not listed as a subtype (see mapper config)
assertThrows(InvalidTypeIdException.class, () -> MAPPER.readValue(foo2, FooMinClass.class));
}
}