Skip to content

feat: Allow accepting a JSON substring using a string instead of throwing an exception. #5232

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

Open
wants to merge 2 commits into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,16 @@ public enum DeserializationFeature implements ConfigFeature
*/
ACCEPT_FLOAT_AS_INT(true),

/**
* Feature that allow accepting a JSON substring using a string
* instead of throwing an exception.
*<p>
* Feature is enabled by default.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is disabled by default - so the comment is wrong

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, I will fix it.

*
* @since 2.20.0
*/
ACCEPT_SUB_JSON_AS_STRING(false),

/**
* Feature that determines standard deserialization mechanism used for
* Enum values: if enabled, Enums are assumed to have been serialized using
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
package com.fasterxml.jackson.databind.deser.std;

import java.io.IOException;
import java.util.*;

import com.fasterxml.jackson.annotation.JsonFormat;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;

import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.BeanProperty;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.JsonDeserializer;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.annotation.JacksonStdImpl;
import com.fasterxml.jackson.databind.cfg.CoercionAction;
import com.fasterxml.jackson.databind.cfg.CoercionInputShape;
Expand All @@ -19,6 +19,12 @@
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import com.fasterxml.jackson.databind.type.LogicalType;

import java.io.IOException;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please do not re-order or expand import statements. Lots of noise & something we'll revert over time anyway.

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.Objects;

/**
* Specifically optimized version for {@link java.util.Collection}s
* that contain String values; reason is that this is a very common
Expand Down Expand Up @@ -197,6 +203,14 @@ public Collection<String> deserialize(JsonParser p, DeserializationContext ctxt,
if (_valueDeserializer != null) {
return deserializeUsingCustom(p, ctxt, result, _valueDeserializer);
}

if (ctxt.isEnabled(DeserializationFeature.ACCEPT_SUB_JSON_AS_STRING)) {
JsonToken currentToken = p.currentToken();
if (currentToken == JsonToken.START_OBJECT || currentToken == JsonToken.START_ARRAY) {
return deserializeUsingCustom(p, ctxt, result, StringDeserializer.instance);
}
}

try {
while (true) {
// First the common case:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
package com.fasterxml.jackson.databind.deser.std;

import java.io.IOException;

import com.fasterxml.jackson.core.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.annotation.JacksonStdImpl;
import com.fasterxml.jackson.databind.jsontype.TypeDeserializer;
import com.fasterxml.jackson.databind.type.LogicalType;

import java.io.IOException;
import java.util.ArrayDeque;
import java.util.Deque;

@JacksonStdImpl
public class StringDeserializer extends StdScalarDeserializer<String> // non-final since 2.9
{
Expand Down Expand Up @@ -36,6 +41,81 @@ public Object getEmptyValue(DeserializationContext ctxt) throws JsonMappingExcep

@Override
public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
{
// disabled, execute default serialization
if (!ctxt.isEnabled(DeserializationFeature.ACCEPT_SUB_JSON_AS_STRING)) {
return defaultDeserialize(p, ctxt);
}

JsonToken currentToken = p.getCurrentToken();

// not a JSON substring, execute default serialization
if (currentToken != JsonToken.START_OBJECT && currentToken != JsonToken.START_ARRAY) {
return defaultDeserialize(p, ctxt);
}

StringBuilder builder = new StringBuilder();
Copy link
Member

@cowtowncoder cowtowncoder Jul 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh. This is madness... are we trying to re-construct JSON BACK from decoded JSON?

No, I don't think this is something to add.

Deque<JsonToken> stack = new ArrayDeque<>();

builder.append(p.getText());
stack.push(currentToken);

final boolean isArray = currentToken == JsonToken.START_ARRAY;
while (!stack.isEmpty()) {
// an empty stack indicates that the current sub JSON string has been searched and completed
JsonToken nextToken = p.nextToken();
if (isArray && nextToken == JsonToken.END_ARRAY ||
!isArray && nextToken == JsonToken.END_OBJECT) {
stack.pop();
}
if (isArray && nextToken == JsonToken.START_ARRAY ||
!isArray && nextToken == JsonToken.START_OBJECT) {
stack.push(nextToken);
}

// start the sub JSON string, add comma if necessary
if (nextToken.isStructStart()) {
appendCommaIfNecessary(builder).append(p.getText());
}

// end of sub JSON string, delete comma if necessary
else if (nextToken.isStructEnd()) {
deleteCommaIfNecessary(builder).append(p.getText());
}

// number, Boolean type, without double quotation marks
else if (nextToken.isNumeric() || nextToken.isBoolean()) {
builder.append(p.getText());
}

// other types automatically add double quotation marks
else {
appendCommaIfNecessary(builder).append('"').append(p.getText()).append('"');
}

// automatically add colon if field
if (nextToken == JsonToken.FIELD_NAME) {
builder.append(':');
}
// automatically add commas if value
else if (nextToken.isScalarValue()) {
builder.append(',');
}
}

return builder.toString();
}

// Since we can never have type info ("natural type"; String, Boolean, Integer, Double):
// (is it an error to even call this version?)
@Override
public String deserializeWithType(JsonParser p, DeserializationContext ctxt,
TypeDeserializer typeDeserializer) throws IOException {
return deserialize(p, ctxt);
}

protected String defaultDeserialize(JsonParser p,
DeserializationContext ctxt) throws IOException
{
// The critical path: ensure we handle the common case first.
if (p.hasToken(JsonToken.VALUE_STRING)) {
Expand All @@ -48,11 +128,19 @@ public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOEx
return _parseString(p, ctxt, this);
}

// Since we can never have type info ("natural type"; String, Boolean, Integer, Double):
// (is it an error to even call this version?)
@Override
public String deserializeWithType(JsonParser p, DeserializationContext ctxt,
TypeDeserializer typeDeserializer) throws IOException {
return deserialize(p, ctxt);
private static StringBuilder appendCommaIfNecessary(StringBuilder builder) {
char lastChar = builder.charAt(builder.length() - 1);
if (lastChar != '{' && lastChar != '[' && lastChar != ':' && lastChar != ',') {
builder.append(',');
}
return builder;
}

private static StringBuilder deleteCommaIfNecessary(StringBuilder builder) {
int lastIndex = builder.length() - 1;
if (builder.charAt(lastIndex) == ',') {
builder.deleteCharAt(lastIndex);
}
return builder;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package com.fasterxml.jackson.databind;

import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.List;

/**
* Test validation uses a string to accept JSON substrings
* instead of throwing exceptions by default
*/
public class StringDeserializerTest
{

@Test
public void acceptSubJsonTest() throws Exception {
String json = "{\"name\":\"root\"," +
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"\"child\":{\"name\":\"child\"}," +
"\"children\":[{\"name\":\"children\"}]," +
"\"childrenList\":[{\"name\":\"childrenList\"}]}";
ObjectMapper mapper = DatabindTestUtil.newJsonMapper()
.configure(DeserializationFeature.ACCEPT_SUB_JSON_AS_STRING, true);
TestPojo testPojo = mapper.readValue(json, TestPojo.class);
Assertions.assertEquals(testPojo.getChild(), "{\"name\":\"child\"}");
Assertions.assertEquals(testPojo.getChildren(), "[{\"name\":\"children\"}]");
Assertions.assertEquals(testPojo.getChildrenList().get(0), "{\"name\":\"childrenList\"}");
}

static class TestPojo {
private String name;
private String child;
private String children;
private List<String> childrenList;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public String getChild() {
return child;
}

public void setChild(String child) {
this.child = child;
}

public String getChildren() {
return children;
}

public void setChildren(String children) {
this.children = children;
}

public List<String> getChildrenList() {
return childrenList;
}

public void setChildrenList(List<String> childrenList) {
this.childrenList = childrenList;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package com.fasterxml.jackson.databind.cfg;

import java.util.Collections;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.annotation.JsonInclude;

import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.AnnotationIntrospector;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyName;
import com.fasterxml.jackson.databind.introspect.ClassIntrospector;
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
import org.junit.jupiter.api.Test;

import java.util.Collections;

import static org.junit.jupiter.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

public class DeserializationConfigTest extends DatabindTestUtil
{
Expand Down Expand Up @@ -101,7 +111,7 @@ public void testEnumIndexes()
for (DeserializationFeature f : DeserializationFeature.values()) {
max = Math.max(max, f.ordinal());
}
if (max >= 31) { // 31 is actually ok; 32 not
if (max >= 32) { // 32 is actually ok; 33 not
fail("Max number of DeserializationFeature enums reached: "+max);
}
}
Expand Down