Skip to content

Commit 7ab4bd8

Browse files
Implement error collection for deserialization (#1196)
- Add CollectedProblem: immutable value object for error details - Add DeferredBindingException: aggregate exception for multiple errors - Add CollectingProblemHandler: stateless handler collecting recoverable errors - Add ObjectReader.collectErrors() and readValueCollecting() methods - Add comprehensive test suite (27 tests) covering all scenarios Features: - Opt-in per-call error collection (no global config) - Thread-safe with per-call bucket isolation - RFC 6901 compliant JSON Pointer paths - DoS protection with configurable limit (default 100) - Primitive vs reference type default value policy - Suppressed exception support for hard failures Tests verify: - Per-call bucket isolation (concurrent + successive) - JSON Pointer escaping (tilde, slash, combined) - Limit reached behavior - Unknown property handling - Default value policy - Message formatting - Edge cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 096695e commit 7ab4bd8

File tree

5 files changed

+1515
-0
lines changed

5 files changed

+1515
-0
lines changed

src/main/java/tools/jackson/databind/ObjectReader.java

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,61 @@ public ObjectReader withHandler(DeserializationProblemHandler h) {
692692
return _with(_config.withHandler(h));
693693
}
694694

695+
/**
696+
* Enables error collection mode by registering a
697+
* {@link tools.jackson.databind.deser.CollectingProblemHandler} with default
698+
* error limit (100 problems).
699+
*
700+
* <p>The returned reader is immutable and thread-safe. Each call to
701+
* {@link #readValueCollecting} allocates a fresh problem bucket, so concurrent
702+
* calls do not interfere.
703+
*
704+
* <p>Usage:
705+
* <pre>
706+
* ObjectReader reader = mapper.reader()
707+
* .forType(MyBean.class)
708+
* .collectErrors();
709+
*
710+
* MyBean bean = reader.readValueCollecting(json);
711+
* </pre>
712+
*
713+
* @return A new ObjectReader configured for error collection
714+
* @since 3.1
715+
*/
716+
public ObjectReader collectErrors() {
717+
return collectErrors(100); // Default limit
718+
}
719+
720+
/**
721+
* Enables error collection mode with a custom problem limit.
722+
*
723+
* <p><b>Thread-safety</b>: The returned reader is immutable and thread-safe.
724+
* Each call to {@link #readValueCollecting} allocates a fresh problem bucket,
725+
* so concurrent calls do not interfere.
726+
*
727+
* @param maxProblems Maximum number of problems to collect (must be > 0)
728+
* @return A new ObjectReader configured for error collection
729+
* @since 3.1
730+
*/
731+
public ObjectReader collectErrors(int maxProblems) {
732+
if (maxProblems <= 0) {
733+
throw new IllegalArgumentException("maxProblems must be positive");
734+
}
735+
736+
// Store ONLY the max limit in config (not the bucket)
737+
// Bucket will be allocated fresh per-call in readValueCollecting()
738+
ContextAttributes attrs = _config.getAttributes()
739+
.withSharedAttribute(tools.jackson.databind.deser.CollectingProblemHandler.ATTR_MAX_PROBLEMS, maxProblems);
740+
741+
DeserializationConfig newConfig = _config
742+
.withHandler(new tools.jackson.databind.deser.CollectingProblemHandler())
743+
.with(attrs);
744+
745+
// Return new immutable reader (no mutable state)
746+
return _new(this, newConfig, _valueType, _rootDeserializer, _valueToUpdate,
747+
_schema, _injectableValues);
748+
}
749+
695750
public ObjectReader with(Base64Variant defaultBase64) {
696751
return _with(_config.with(defaultBase64));
697752
}
@@ -1320,6 +1375,176 @@ public <T> T readValue(TokenBuffer src) throws JacksonException
13201375
_considerFilter(src.asParser(ctxt) , false));
13211376
}
13221377

1378+
/*
1379+
/**********************************************************************
1380+
/* Deserialization methods with error collection
1381+
/**********************************************************************
1382+
*/
1383+
1384+
/**
1385+
* Deserializes JSON content into a Java object, collecting multiple
1386+
* errors if encountered. If any problems were collected, throws
1387+
* {@link tools.jackson.databind.exc.DeferredBindingException} with all problems.
1388+
*
1389+
* <p>On hard failures (non-recoverable errors), the original exception
1390+
* is thrown with collected problems attached as suppressed exceptions.
1391+
*
1392+
* <p><b>Thread-safety</b>: Each call allocates a fresh problem bucket,
1393+
* so multiple concurrent calls on the same reader instance are safe.
1394+
*
1395+
* <p>This method should only be called on an ObjectReader created via
1396+
* {@link #collectErrors()}. If called on a regular reader, it behaves
1397+
* the same as {@link #readValue(JsonParser)}.
1398+
*
1399+
* @throws tools.jackson.databind.exc.DeferredBindingException if recoverable problems were collected
1400+
* @throws tools.jackson.databind.DatabindException if a non-recoverable error occurred
1401+
* @since 3.1
1402+
*/
1403+
public <T> T readValueCollecting(JsonParser p) throws JacksonException {
1404+
_assertNotNull("p", p);
1405+
1406+
// CRITICAL: Allocate a FRESH bucket for THIS call (thread-safety)
1407+
List<tools.jackson.databind.exc.CollectedProblem> bucket = new ArrayList<>();
1408+
1409+
// Create per-call attributes with the fresh bucket
1410+
ContextAttributes perCallAttrs = _config.getAttributes()
1411+
.withPerCallAttribute(tools.jackson.databind.deser.CollectingProblemHandler.class, bucket);
1412+
1413+
// Create a temporary ObjectReader with per-call attributes
1414+
// This matches the existing API surface (no new internal methods needed)
1415+
ObjectReader perCallReader = _new(this,
1416+
_config.with(perCallAttrs),
1417+
_valueType, _rootDeserializer, _valueToUpdate,
1418+
_schema, _injectableValues);
1419+
1420+
try {
1421+
// Delegate to the temporary reader's existing readValue method
1422+
T result = perCallReader.readValue(p);
1423+
1424+
// Check if any problems were collected
1425+
if (!bucket.isEmpty()) {
1426+
// Check if limit was reached
1427+
Integer maxProblems = (Integer) _config.getAttributes()
1428+
.getAttribute(tools.jackson.databind.deser.CollectingProblemHandler.ATTR_MAX_PROBLEMS);
1429+
boolean limitReached = (maxProblems != null &&
1430+
bucket.size() >= maxProblems);
1431+
1432+
throw new tools.jackson.databind.exc.DeferredBindingException(p, bucket, limitReached);
1433+
}
1434+
1435+
return result;
1436+
1437+
} catch (tools.jackson.databind.exc.DeferredBindingException e) {
1438+
throw e; // Already properly formatted
1439+
1440+
} catch (DatabindException e) {
1441+
// Hard failure occurred; attach collected problems as suppressed
1442+
if (!bucket.isEmpty()) {
1443+
Integer maxProblems = (Integer) _config.getAttributes()
1444+
.getAttribute(tools.jackson.databind.deser.CollectingProblemHandler.ATTR_MAX_PROBLEMS);
1445+
boolean limitReached = (maxProblems != null &&
1446+
bucket.size() >= maxProblems);
1447+
1448+
e.addSuppressed(new tools.jackson.databind.exc.DeferredBindingException(p, bucket, limitReached));
1449+
}
1450+
throw e;
1451+
}
1452+
}
1453+
1454+
/**
1455+
* Convenience overload for {@link #readValueCollecting(JsonParser)}.
1456+
*/
1457+
public <T> T readValueCollecting(String content) throws JacksonException {
1458+
_assertNotNull("content", content);
1459+
DeserializationContextExt ctxt = _deserializationContext();
1460+
JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, content), true);
1461+
try {
1462+
return readValueCollecting(p);
1463+
} finally {
1464+
try {
1465+
p.close();
1466+
} catch (Exception e) {
1467+
// ignore
1468+
}
1469+
}
1470+
}
1471+
1472+
/**
1473+
* Convenience overload for {@link #readValueCollecting(JsonParser)}.
1474+
*/
1475+
@SuppressWarnings("unchecked")
1476+
public <T> T readValueCollecting(byte[] content) throws JacksonException {
1477+
_assertNotNull("content", content);
1478+
DeserializationContextExt ctxt = _deserializationContext();
1479+
JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, content), true);
1480+
try {
1481+
return readValueCollecting(p);
1482+
} finally {
1483+
try {
1484+
p.close();
1485+
} catch (Exception e) {
1486+
// ignore
1487+
}
1488+
}
1489+
}
1490+
1491+
/**
1492+
* Convenience overload for {@link #readValueCollecting(JsonParser)}.
1493+
*/
1494+
@SuppressWarnings("unchecked")
1495+
public <T> T readValueCollecting(File src) throws JacksonException {
1496+
_assertNotNull("src", src);
1497+
DeserializationContextExt ctxt = _deserializationContext();
1498+
JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true);
1499+
try {
1500+
return readValueCollecting(p);
1501+
} finally {
1502+
try {
1503+
p.close();
1504+
} catch (Exception e) {
1505+
// ignore
1506+
}
1507+
}
1508+
}
1509+
1510+
/**
1511+
* Convenience overload for {@link #readValueCollecting(JsonParser)}.
1512+
*/
1513+
@SuppressWarnings("unchecked")
1514+
public <T> T readValueCollecting(InputStream src) throws JacksonException {
1515+
_assertNotNull("src", src);
1516+
DeserializationContextExt ctxt = _deserializationContext();
1517+
JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true);
1518+
try {
1519+
return readValueCollecting(p);
1520+
} finally {
1521+
try {
1522+
p.close();
1523+
} catch (Exception e) {
1524+
// ignore
1525+
}
1526+
}
1527+
}
1528+
1529+
/**
1530+
* Convenience overload for {@link #readValueCollecting(JsonParser)}.
1531+
*/
1532+
@SuppressWarnings("unchecked")
1533+
public <T> T readValueCollecting(Reader src) throws JacksonException {
1534+
_assertNotNull("src", src);
1535+
DeserializationContextExt ctxt = _deserializationContext();
1536+
JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true);
1537+
try {
1538+
return readValueCollecting(p);
1539+
} finally {
1540+
try {
1541+
p.close();
1542+
} catch (Exception e) {
1543+
// ignore
1544+
}
1545+
}
1546+
}
1547+
13231548
/*
13241549
/**********************************************************************
13251550
/* Deserialization methods; JsonNode ("tree")

0 commit comments

Comments
 (0)