jackson-databind icon indicating copy to clipboard operation
jackson-databind copied to clipboard

@JsonTypeInfo(include = EXTERNAL_PROPERTY) exposes bogus array via `JsonStreamContext` during deserialization

Open dattia-dev opened this issue 5 years ago • 3 comments

First experienced with 2.10.3 but same behaviour occurs with 2.11.0.

Use of EXTERNAL_PROPERTY causes unexpected state in JsonStreamContext resulting in exceptions or invalid results where the context is inspected during deserialisation.

The sample below exhibits both effects that seem to be caused by the JsonStreamContext 'being inside' an array during deserialisation, even though the JSON document contains no arrays.

import java.util.ArrayList;
import java.util.List;

import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonTypeName;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonStreamContext;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;

public class Main
{
	public static void main(String[] args) throws Exception
	{
		ObjectMapper objectMapper = new ObjectMapper();
		ObjectReader objectReader = objectMapper.readerFor(Wrapper.class);
		
		Wrapper wrapper = objectReader.readValue("{" +
			"\"type\":\"location\"," +
			"\"wrapped\": 1" +
			"}");
		// expecting wrapper.wrapped.value == "wrapped" but is "wrapped[1]"
		System.out.println(((Location) wrapper.wrapped).value);
		
		wrapper = objectReader.readValue("{" +
			"\"type\":\"location\"," +
			"\"wrapped\": {}" +
			"}");
		// expecting same as above as the value of wrapped is unused
		// by the Location deserializer, but fails with exception
		// as the context seems to think it's inside an array
	}
	
	static class Wrapper {
		@JsonProperty
		@JsonTypeInfo(
			use = JsonTypeInfo.Id.NAME,
			property = "type",
			include = JsonTypeInfo.As.EXTERNAL_PROPERTY)
		Tag wrapped;
		
		@JsonProperty
		String type;
	}
	
	@JsonSubTypes(@JsonSubTypes.Type(Location.class))
	interface Tag {}
	
	@JsonTypeName("location")
	@JsonDeserialize(using = LocationDeserializer.class)
	static class Location implements Tag
	{
		/**
		 * The location of this in the JSON document.
		 */
		String value;
	}
	
	static class LocationDeserializer extends JsonDeserializer<Location>
	{
		@Override
		public Location deserialize(JsonParser parser,
			DeserializationContext deserializationContext)
		{
			Location location = new Location();
			location.value = getCurrentLocationAsString(parser);
			return location;
		}
	}
	
	static String getCurrentLocationAsString(JsonParser parser)
	{
		JsonStreamContext currentContext = parser.getParsingContext();
		List<JsonStreamContext> contexts = new ArrayList<>();
		while (currentContext != null)
		{
			contexts.add(0, currentContext);
			currentContext = currentContext.getParent();
		}
		StringBuilder builder = new StringBuilder();
		for (JsonStreamContext context : contexts)
		{
			if (context.inObject())
			{
				String name = context.getCurrentName();
				if (name != null)
				{
					if (builder.length() > 0)
					{
						builder.append('.');
					}
					builder.append(name);
				}
			}
			else if (context.inArray())
			{
				builder.append('[').append(context.getCurrentIndex()).append(']');
			}
		}
		return builder.toString();
	}
}

Output:

wrapped[1]
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (END_OBJECT), expected END_ARRAY: expected closing END_ARRAY after type information and deserialized value
 at [Source: (String)"{"type":"location","wrapped": {}}"; line: 1, column: 32]
	at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
	at com.fasterxml.jackson.databind.DeserializationContext.wrongTokenException(DeserializationContext.java:1646)
	at com.fasterxml.jackson.databind.DeserializationContext.reportWrongTokenException(DeserializationContext.java:1396)
	at com.fasterxml.jackson.databind.jsontype.impl.AsArrayTypeDeserializer._deserialize(AsArrayTypeDeserializer.java:123)
	at com.fasterxml.jackson.databind.jsontype.impl.AsArrayTypeDeserializer.deserializeTypedFromObject(AsArrayTypeDeserializer.java:61)
	at com.fasterxml.jackson.databind.deser.AbstractDeserializer.deserializeWithType(AbstractDeserializer.java:254)
	at com.fasterxml.jackson.databind.deser.impl.FieldProperty.deserializeAndSet(FieldProperty.java:147)
	at com.fasterxml.jackson.databind.deser.impl.ExternalTypeHandler._deserializeAndSet(ExternalTypeHandler.java:372)
	at com.fasterxml.jackson.databind.deser.impl.ExternalTypeHandler.handlePropertyValue(ExternalTypeHandler.java:191)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeWithExternalTypeId(BeanDeserializer.java:943)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeWithExternalTypeId(BeanDeserializer.java:907)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:329)
	at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:164)
	at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2057)
	at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1496)
	at Main.main(Main.java:30)

dattia-dev avatar Jun 05 '20 11:06 dattia-dev

First things first: the second case is due to minor bug in LocationDeserializer implementation: it does not consume input it needs to: there has to be something like

        @Override
        public Location deserialize(JsonParser parser,
             DeserializationContext deserializationContext) throws IOException
        {
            parser.skipChildren();
            // ^^^^ MUST consume all content; only matters for structured values (Arrays, Objects)
            Location location = new Location();
            location.value = getCurrentLocationAsString(parser);
            return location;
        }

so the stack trace indicates a problem, but not in internal handling. The reason problem does not occur with first input is because it consists with just a single token (number 1); but in second case { } is a sequence of 2 tokens.

This leaves the location problem which is due to the way EXTERNAL_PROPERTY is implemented: content is buffered and enclosed in virtual array. I will see if there is a way to fix this or not.

cowtowncoder avatar Jun 08 '20 22:06 cowtowncoder

Ok so yes, array is included because the internal implementation extracts type id and payload separately (as they are siblings, not wrapped), then combines to look like "as wrapper array" mechanism was used. What parses sees is what it reports. Ideally this should be changed, so I added a (failing) unit test. But I do not have time to try to do this now as changes needed are likely significant -- and the whole handling of this type id should be rewritten along with "unwrapped" content. This is planned to be tackled for 3.0, eventually.

cowtowncoder avatar Jun 08 '20 23:06 cowtowncoder

I see, thank you. As our payloads aren't that big, I'll work around the issue with custom tree-model deserialisation of the wrapper for the time being.

dattia-dev avatar Jun 09 '20 07:06 dattia-dev