Skip to content

Instantly share code, notes, and snippets.

@Anton3
Last active May 11, 2026 08:09
Show Gist options
  • Select an option

  • Save Anton3/348e639b6d46c3598f3311b9feca8578 to your computer and use it in GitHub Desktop.

Select an option

Save Anton3/348e639b6d46c3598f3311b9feca8578 to your computer and use it in GitHub Desktop.
package name.anton3.vkapi.generator.json
import com.fasterxml.jackson.annotation.JsonUnwrapped
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.*
import com.fasterxml.jackson.databind.deser.ContextualDeserializer
import com.fasterxml.jackson.databind.deser.ResolvableDeserializer
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.databind.node.TreeTraversingParser
import com.fasterxml.jackson.databind.util.NameTransformer
class SinglePolyUnwrappedDeserializer<T : Any> : JsonDeserializer<T>(), ContextualDeserializer {
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T = error("Not implemented")
override fun createContextual(ctxt: DeserializationContext, property: BeanProperty?): JsonDeserializer<T> =
SinglePolyUnwrappedDeserializerImpl(ctxt)
}
private class SinglePolyUnwrappedDeserializerImpl<T : Any>(ctxt: DeserializationContext) :
StdDeserializer<T>(null as JavaType?) {
private val type: JavaType = ctxt.contextualType
private val beanDeserializer: JsonDeserializer<T>
private val ownPropertyNames: Set<String>
private val unwrappedType: JavaType
private val unwrappedPropertyName: String
private val nameTransformer: NameTransformer
init {
val description: BeanDescription = ctxt.config.introspect(type)
var tempUnwrappedAnnotation: JsonUnwrapped? = null
val unwrappedProperties = description.findProperties().filter { prop ->
listOfNotNull(prop.constructorParameter, prop.mutator, prop.field).any { member ->
val unwrappedAnnotation: JsonUnwrapped? = member.getAnnotation(JsonUnwrapped::class.java)
if (unwrappedAnnotation != null) {
tempUnwrappedAnnotation = unwrappedAnnotation
member.allAnnotations.add(notUnwrappedAnnotation)
}
unwrappedAnnotation != null
}
}
val unwrappedProperty = when (unwrappedProperties.size) {
0 -> error("@JsonUnwrapped properties not found in ${type.typeName}")
1 -> unwrappedProperties.single()
else -> error("Multiple @JsonUnwrapped properties found in ${type.typeName}")
}
nameTransformer = tempUnwrappedAnnotation!!.run { NameTransformer.simpleTransformer(prefix, suffix) }
unwrappedPropertyName = unwrappedProperty.name
ownPropertyNames = description.findProperties().mapTo(mutableSetOf()) { it.name }
ownPropertyNames.remove(unwrappedPropertyName)
ownPropertyNames.removeAll(description.ignoredPropertyNames)
unwrappedType = unwrappedProperty.primaryType
val rawBeanDeserializer = ctxt.factory.createBeanDeserializer(ctxt, type, description)
(rawBeanDeserializer as? ResolvableDeserializer)?.resolve(ctxt)
@Suppress("UNCHECKED_CAST")
beanDeserializer = rawBeanDeserializer as JsonDeserializer<T>
}
override fun deserialize(p: JsonParser, ctxt: DeserializationContext): T {
val node = p.readValueAsTree<ObjectNode>()
val ownNode = ObjectNode(ctxt.nodeFactory)
val unwrappedNode = ObjectNode(ctxt.nodeFactory)
node.fields().forEach { (key, value) ->
val transformed: String? = nameTransformer.reverse(key)
if (transformed != null && key !in ownPropertyNames) {
unwrappedNode.replace(transformed, value)
} else {
ownNode.replace(key, value)
}
}
ownNode.replace(unwrappedPropertyName, unwrappedNode)
val syntheticParser = TreeTraversingParser(ownNode)
syntheticParser.nextToken()
return beanDeserializer.deserialize(syntheticParser, ctxt)
}
private class NotUnwrapped(
@Suppress("unused")
@field:JsonUnwrapped(enabled = false)
@JvmField
val dummy: Nothing
)
companion object {
val notUnwrappedAnnotation: JsonUnwrapped =
NotUnwrapped::class.java.getField("dummy").getAnnotation(JsonUnwrapped::class.java)
}
}
@nekator
Copy link
Copy Markdown

nekator commented Jul 25, 2025

This deserializer saved me after 2 days of desperation. I can't believe that Jackson still doesn't support polymorphism on Unwrapped properties

@bartekgruba
Copy link
Copy Markdown

bartekgruba commented May 11, 2026

In case anyone stumbled upon the same issue, we just recently upgraded our project to java 25 and spring boot 4 and this deserializer stopped working because of the jackson 3 version and so on. It was parametrized for a specific class, with one custom statement regarding '@'type field for mapping purposes. Its not perfect but maybe helps someone save time. With the help of claude, here is a working version adjusted to the above-mentioned setup:

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonUnwrapped;
import tools.jackson.core.JsonParser;
import tools.jackson.databind.BeanDescription;
import tools.jackson.databind.BeanProperty;
import tools.jackson.databind.DeserializationContext;
import tools.jackson.databind.JavaType;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ValueDeserializer;
import tools.jackson.databind.cfg.MapperConfig;
import tools.jackson.databind.deser.std.StdDeserializer;
import tools.jackson.databind.introspect.Annotated;
import tools.jackson.databind.introspect.AnnotatedMember;
import tools.jackson.databind.introspect.BeanPropertyDefinition;
import tools.jackson.databind.introspect.JacksonAnnotationIntrospector;
import tools.jackson.databind.json.JsonMapper;
import tools.jackson.databind.node.ObjectNode;
import tools.jackson.databind.util.NameTransformer;

/**
 * Workaround for Jackson's @JsonUnwrapped not working with polymorphism (@JsonTypeInfo).
 * Adapted from https://stackoverflow.com/questions/37423848/deserializing-polymorphic-types-with-jsonunwrapped-using-jackson
 * Based on java adaptation by https://github.com/author zhiwei.wei
 from 2021/09/07 10:55
 */
public class SinglePolyUnwrappedDeserializer extends ValueDeserializer<YourDTO> {

    @Override
    public YourDTO deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) {
        throw new UnsupportedOperationException("not implemented");
    }

    private static class Impl extends StdDeserializer<YourDTO> {
        private final ObjectMapper modifiedMapper;
        private final Set<String> ownPropertyNames;
        private final String unwrappedPropertyName;
        private final NameTransformer nameTransformer;

        public Impl(DeserializationContext deserializationContext, BeanProperty beanProperty) {
            super(Objects.class);
            JavaType type = deserializationContext.getContextualType();

            BeanDescription description = deserializationContext.getConfig()
                    .classIntrospectorInstance().introspectForDeserialization(type,
                            deserializationContext.getConfig().classIntrospectorInstance().introspectClassAnnotations(type));

            final JsonUnwrapped[] tempUnwrappedAnnotation = {null};

            List<BeanPropertyDefinition> unwrappedProperties = description.findProperties().stream()
                    .filter(prop -> Arrays.asList(prop.getConstructorParameter(), prop.getMutator(), prop.getField())
                            .stream()
                            .filter(Objects::nonNull)
                            .anyMatch(member -> {
                                JsonUnwrapped ann = member.getAnnotation(JsonUnwrapped.class);
                                if (ann != null) {
                                    tempUnwrappedAnnotation[0] = ann;
                                }
                                return ann != null;
                            }))
                    .collect(Collectors.toList());

            if (unwrappedProperties.isEmpty()) {
                throw new UnsupportedOperationException("@JsonUnwrapped properties not found in " + type.getTypeName());
            } else if (unwrappedProperties.size() > 1) {
                throw new UnsupportedOperationException("Multiple @JsonUnwrapped properties found in " + type.getTypeName());
            }

            BeanPropertyDefinition unwrappedProperty = unwrappedProperties.get(0);

            nameTransformer = NameTransformer.simpleTransformer(
                    tempUnwrappedAnnotation[0].prefix(), tempUnwrappedAnnotation[0].suffix());
            unwrappedPropertyName = unwrappedProperty.getName();

            ownPropertyNames = description.findProperties().stream()
                    .map(BeanPropertyDefinition::getName)
                    .collect(Collectors.toSet());
            ownPropertyNames.remove(unwrappedPropertyName);
            ownPropertyNames.removeAll(description.getIgnoredPropertyNames());
            
            JsonTypeInfo typeInfoAnnotation = type.getRawClass().getAnnotation(JsonTypeInfo.class);
            if (typeInfoAnnotation != null && !typeInfoAnnotation.property().isEmpty()) {
                ownPropertyNames.add(typeInfoAnnotation.property());
            }

            final String propName = unwrappedPropertyName;
            final Class<?> rawClass = type.getRawClass();
            
            modifiedMapper = JsonMapper.builder()
                    .annotationIntrospector(new JacksonAnnotationIntrospector() {
                        @Override
                        public NameTransformer findUnwrappingNameTransformer(
                                MapperConfig<?> config, AnnotatedMember member) {
                            if (rawClass.isAssignableFrom(member.getDeclaringClass())
                                    && propName.equals(member.getName())) {
                                return null; // cancel @JsonUnwrapped
                            }
                            return super.findUnwrappingNameTransformer(config, member);
                        }

                        @Override
                        public Object findDeserializer(MapperConfig<?> config, Annotated a) {
                            if (rawClass.isAssignableFrom(a.getRawType())) {
                                return null;
                            }
                            return super.findDeserializer(config, a);
                        }
                    })
                    .build();
        }

        @Override
        public YourDTO deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) {
            JsonNode node = deserializationContext.readTree(jsonParser);

            ObjectNode ownNode = deserializationContext.getNodeFactory().objectNode();
            ObjectNode unwrappedNode = deserializationContext.getNodeFactory().objectNode();

            node.properties().forEach(entry -> {
                String key = entry.getKey();
                JsonNode value = entry.getValue();
                String transformed = nameTransformer.reverse(key);
                if (transformed != null && !ownPropertyNames.contains(key) && !transformed.equals("@type")) {
                    unwrappedNode.replace(transformed, value);
                } else {
                    ownNode.replace(key, value);
                }
            });

            ownNode.replace(unwrappedPropertyName, unwrappedNode);
            return modifiedMapper.treeToValue(ownNode, YourDTO.class);
        }
    }

    @Override
    public ValueDeserializer<YourDTO> createContextual(
            DeserializationContext deserializationContext, BeanProperty beanProperty) {
        return new Impl(deserializationContext, beanProperty);
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment