diff --git a/README.md b/README.md index 0d81ec6ca3..b995a9884f 100644 --- a/README.md +++ b/README.md @@ -581,7 +581,60 @@ This will deserialize JSON fields with `known_as`, as well as `identifer` and `f Note: to use the `@JsonAlias` annotation, a `@JsonProperty` annotation must also be used. Overall, Jackson library is very powerful in deserializing objects using builder pattern. - + +## Tutorial: Collecting multiple errors (3.1+) + +One recently introduced feature is the ability to collect multiple deserialization errors instead of failing fast on the first one. This can be really handy for validation use cases. + +By default, if Jackson encounters a problem during deserialization -- say, string `"xyz"` for an `int` property -- it will immediately throw an exception and stop. But sometimes you want to see ALL the problems in one go. + +Consider a case where you have a couple of fields with bad data: + +```java +class Order { + public int orderId; + public Date orderDate; + public double amount; +} + +String json = "{\"orderId\":\"not-a-number\",\"orderDate\":\"bad-date\",\"amount\":\"xyz\"}"; +``` + +Normally you'd get an error about `orderId`, fix it, resubmit, then get error about `orderDate`, and so on. Not fun. So let's collect them all: + +```java +ObjectMapper mapper = new JsonMapper(); +ObjectReader reader = mapper.readerFor(Order.class).problemCollectingReader(); + +try { + Order result = reader.readValueCollectingProblems(json); + // worked fine +} catch (DeferredBindingException ex) { + System.out.println("Found " + ex.getProblems().size() + " problems:"); + for (CollectedProblem problem : ex.getProblems()) { + System.out.println(problem.getPath() + ": " + problem.getMessage()); + // Can also access problem.getRawValue() to see what the bad input was + } +} +``` + +This will report all 3 problems at once. Much better. + +By default, Jackson will collect up to 100 problems before giving up (to prevent DoS-style attacks with huge bad payloads). You can configure this: + +```java +ObjectReader reader = mapper.readerFor(Order.class).problemCollectingReader(10); // limit to 10 +``` + +Few things to keep in mind: + +1. This is best-effort: not all problems can be collected. Malformed JSON (like missing closing brace) or other structural problems will still fail immediately. But type conversion errors, unknown properties (if you enable that check), and such will be collected. +2. Error paths use JSON Pointer notation (RFC 6901): so `"/items/0/price"` means first item in `items` array, `price` field. Special characters get escaped (`~` becomes `~0`, `/` becomes `~1`). +3. Each call to `readValueCollectingProblems()` gets its own problem bucket, so it's thread-safe to reuse the same `ObjectReader`. +4. Fields that fail to deserialize get default values (0 for primitives, null for objects), so you do get a result object back (thrown in the exception). + +This is particularly useful for things like REST API validation (return all validation errors to client), or batch processing (log errors but keep going), or development tooling. + # Contribute! We would love to get your contribution, whether it's in form of bug reports, Requests for Enhancement (RFE), documentation, or code patches. diff --git a/release-notes/VERSION b/release-notes/VERSION index 3a7410c418..2f4e52c105 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -7,6 +7,8 @@ Versions: 3.x (for earlier see VERSION-2.x) 3.1.0 (not yet released) +#1196: Add opt-in error collection for deserialization + (requested by @odrotbohm, contributed by @sri-adarsh-kumar) #5350: Add `DeserializationFeature.USE_NULL_FOR_MISSING_REFERENCE_VALUES` for selecting `null` vs "empty/absent" value when deserializing missing `Optional` value diff --git a/src/main/java/tools/jackson/databind/ObjectReader.java b/src/main/java/tools/jackson/databind/ObjectReader.java index 1346bdf0df..0ea174a961 100644 --- a/src/main/java/tools/jackson/databind/ObjectReader.java +++ b/src/main/java/tools/jackson/databind/ObjectReader.java @@ -17,8 +17,11 @@ import tools.jackson.databind.cfg.ContextAttributes; import tools.jackson.databind.cfg.DatatypeFeature; import tools.jackson.databind.cfg.DeserializationContexts; +import tools.jackson.databind.deser.CollectingProblemHandler; import tools.jackson.databind.deser.DeserializationContextExt; import tools.jackson.databind.deser.DeserializationProblemHandler; +import tools.jackson.databind.exc.CollectedProblem; +import tools.jackson.databind.exc.DeferredBindingException; import tools.jackson.databind.node.ArrayNode; import tools.jackson.databind.node.JsonNodeFactory; import tools.jackson.databind.node.ObjectNode; @@ -692,6 +695,68 @@ public ObjectReader withHandler(DeserializationProblemHandler h) { return _with(_config.withHandler(h)); } + /** + * Returns a new {@link ObjectReader} configured to collect deserialization problems + * instead of failing on the first error. Uses default problem limit (100 problems). + * + *

IMPORTANT: This method registers a {@link CollectingProblemHandler} which + * replaces any previously configured {@link DeserializationProblemHandler}. + * If you need custom problem handling in addition to collection, you must implement + * your own handler that delegates to {@code CollectingProblemHandler} or chain handlers. + * + *

Future versions may support handler chaining; for now, only one handler is active. + * + *

Thread-safety: The returned reader is immutable and thread-safe. Each call to + * {@link #readValueCollectingProblems} allocates a fresh problem bucket, so concurrent + * calls do not interfere. + * + *

Usage: + *

+     * ObjectReader reader = mapper.reader()
+     *     .forType(MyBean.class)
+     *     .problemCollectingReader();
+     *
+     * MyBean bean = reader.readValueCollectingProblems(json);
+     * 
+ * + * @return A new ObjectReader configured for problem collection + * @since 3.1 + */ + public ObjectReader problemCollectingReader() { + return problemCollectingReader(100); // Default limit + } + + /** + * Enables problem collection mode with a custom problem limit. + * + *

Thread-safety: The returned reader is immutable and thread-safe. + * Each call to {@link #readValueCollectingProblems} allocates a fresh problem bucket, + * so concurrent calls do not interfere. + * + * @param maxProblems Maximum number of problems to collect (must be > 0) + * @return A new ObjectReader configured for problem collection + * @throws IllegalArgumentException if maxProblems is <= 0 + * @since 3.1 + */ + public ObjectReader problemCollectingReader(int maxProblems) { + if (maxProblems <= 0) { + throw new IllegalArgumentException("maxProblems must be positive"); + } + + // Store ONLY the max limit in config (not the bucket) + // Bucket will be allocated fresh per-call in readValueCollectingProblems() + ContextAttributes attrs = _config.getAttributes() + .withSharedAttribute(CollectingProblemHandler.ATTR_MAX_PROBLEMS, maxProblems); + + DeserializationConfig newConfig = _config + .withHandler(new CollectingProblemHandler()) + .with(attrs); + + // Return new immutable reader (no mutable state) + return _new(this, newConfig, _valueType, _rootDeserializer, _valueToUpdate, + _schema, _injectableValues); + } + public ObjectReader with(Base64Variant defaultBase64) { return _with(_config.with(defaultBase64)); } @@ -1320,6 +1385,207 @@ public T readValue(TokenBuffer src) throws JacksonException _considerFilter(src.asParser(ctxt) , false)); } + /* + /********************************************************************** + /* Deserialization methods with error collection + /********************************************************************** + */ + + /** + * Deserializes JSON content into a Java object, collecting multiple + * problems if encountered. If any problems were collected, throws + * {@link DeferredBindingException} with all problems. + * + *

Usage: This method should be called on an ObjectReader created via + * {@link #problemCollectingReader()} or {@link #problemCollectingReader(int)}. If called on a regular + * reader (without problem collection enabled), it behaves the same as + * {@link #readValue(JsonParser)} since no handler is registered. + * + *

Error handling: + *

+ * + *

Exception Handling Strategy: + * + *

This method catches only {@link DatabindException} subtypes (not all + * {@link JacksonException}s) because: + * + *

+ * + *

If a hard failure occurs after some problems have been collected, those problems + * are attached as suppressed exceptions to the thrown exception for debugging purposes. + * + *

Thread-safety: Each call allocates a fresh problem bucket, + * so multiple concurrent calls on the same reader instance are safe. + * + *

Parser filtering: Unlike convenience overloads ({@link #readValueCollectingProblems(String)}, + * {@link #readValueCollectingProblems(byte[])}, etc.), this method does not apply + * parser filtering. Callers are responsible for filter wrapping if needed. + * + * @param Type to deserialize + * @param p JsonParser to read from (will not be closed by this method) + * @return Deserialized object + * @throws DeferredBindingException if recoverable problems were collected + * @throws DatabindException if a non-recoverable error occurred + * @since 3.1 + */ + public T readValueCollectingProblems(JsonParser p) throws JacksonException { + _assertNotNull("p", p); + + // CRITICAL: Allocate a FRESH bucket for THIS call (thread-safety) + List bucket = new ArrayList<>(); + + // Create per-call attributes with the fresh bucket + ContextAttributes perCallAttrs = _config.getAttributes() + .withPerCallAttribute(CollectingProblemHandler.class, bucket); + + // Create a temporary ObjectReader with per-call attributes using public API + ObjectReader perCallReader = this.with(perCallAttrs); + + try { + // Delegate to the temporary reader's existing readValue method + T result = perCallReader.readValue(p); + + // Check if any problems were collected + if (!bucket.isEmpty()) { + // Check if limit was reached - read from per-call config to honor overrides + Integer maxProblems = (Integer) perCallReader.getConfig().getAttributes() + .getAttribute(CollectingProblemHandler.ATTR_MAX_PROBLEMS); + boolean limitReached = (maxProblems != null && + bucket.size() >= maxProblems); + + throw new DeferredBindingException(p, bucket, limitReached); + } + + return result; + + } catch (DeferredBindingException e) { + throw e; // Already properly formatted + + } catch (DatabindException e) { + // Hard failure occurred; attach collected problems as suppressed + if (!bucket.isEmpty()) { + // Read from per-call config to honor overrides + Integer maxProblems = (Integer) perCallReader.getConfig().getAttributes() + .getAttribute(CollectingProblemHandler.ATTR_MAX_PROBLEMS); + boolean limitReached = (maxProblems != null && + bucket.size() >= maxProblems); + + e.addSuppressed(new DeferredBindingException(p, bucket, limitReached)); + } + throw e; + } + } + + /** + * Convenience overload for {@link #readValueCollectingProblems(JsonParser)}. + */ + public T readValueCollectingProblems(String content) throws JacksonException { + _assertNotNull("content", content); + DeserializationContextExt ctxt = _deserializationContext(); + JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, content), true); + try { + return readValueCollectingProblems(p); + } finally { + try { + p.close(); + } catch (Exception e) { + // ignore + } + } + } + + /** + * Convenience overload for {@link #readValueCollectingProblems(JsonParser)}. + */ + @SuppressWarnings("unchecked") + public T readValueCollectingProblems(byte[] content) throws JacksonException { + _assertNotNull("content", content); + DeserializationContextExt ctxt = _deserializationContext(); + JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, content), true); + try { + return readValueCollectingProblems(p); + } finally { + try { + p.close(); + } catch (Exception e) { + // ignore + } + } + } + + /** + * Convenience overload for {@link #readValueCollectingProblems(JsonParser)}. + */ + @SuppressWarnings("unchecked") + public T readValueCollectingProblems(File src) throws JacksonException { + _assertNotNull("src", src); + DeserializationContextExt ctxt = _deserializationContext(); + JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true); + try { + return readValueCollectingProblems(p); + } finally { + try { + p.close(); + } catch (Exception e) { + // ignore + } + } + } + + /** + * Convenience overload for {@link #readValueCollectingProblems(JsonParser)}. + */ + @SuppressWarnings("unchecked") + public T readValueCollectingProblems(InputStream src) throws JacksonException { + _assertNotNull("src", src); + DeserializationContextExt ctxt = _deserializationContext(); + JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true); + try { + return readValueCollectingProblems(p); + } finally { + try { + p.close(); + } catch (Exception e) { + // ignore + } + } + } + + /** + * Convenience overload for {@link #readValueCollectingProblems(JsonParser)}. + */ + @SuppressWarnings("unchecked") + public T readValueCollectingProblems(Reader src) throws JacksonException { + _assertNotNull("src", src); + DeserializationContextExt ctxt = _deserializationContext(); + JsonParser p = _considerFilter(_parserFactory.createParser(ctxt, src), true); + try { + return readValueCollectingProblems(p); + } finally { + try { + p.close(); + } catch (Exception e) { + // ignore + } + } + } + /* /********************************************************************** /* Deserialization methods; JsonNode ("tree") diff --git a/src/main/java/tools/jackson/databind/deser/CollectingProblemHandler.java b/src/main/java/tools/jackson/databind/deser/CollectingProblemHandler.java new file mode 100644 index 0000000000..585b00e3be --- /dev/null +++ b/src/main/java/tools/jackson/databind/deser/CollectingProblemHandler.java @@ -0,0 +1,367 @@ +package tools.jackson.databind.deser; + +import java.util.ArrayList; +import java.util.List; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonPointer; +import tools.jackson.core.JsonToken; +import tools.jackson.core.TokenStreamContext; +import tools.jackson.core.TokenStreamLocation; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.exc.CollectedProblem; + +/** + * Stateless {@link DeserializationProblemHandler} that collects recoverable + * problems into a per-call bucket stored in {@link DeserializationContext} + * attributes. + * + *

Design: This handler is completely stateless. The problem collection + * bucket is allocated per-call by + * {@link tools.jackson.databind.ObjectReader#readValueCollectingProblems ObjectReader.readValueCollectingProblems(...)} + * and stored in per-call {@link tools.jackson.databind.cfg.ContextAttributes ContextAttributes}, + * ensuring thread-safety and call isolation. + * + *

Usage: This class is internal infrastructure, registered automatically by + * {@link tools.jackson.databind.ObjectReader#problemCollectingReader() ObjectReader.problemCollectingReader()}. + * Users should not instantiate or register this handler manually. + * + *

Design rationale - Context Attributes vs Handler State: + * + *

Problem collection state is stored in {@link DeserializationContext} attributes + * rather than within this handler for several reasons: + * + *

    + *
  1. Thread-safety: The handler instance is shared across all calls to the + * same ObjectReader. Storing mutable state in the handler would require + * synchronization and complicate the implementation.
  2. + * + *
  3. Call isolation: Each call to {@code readValueCollectingProblems()} needs + * its own problem bucket. Context attributes are perfect for this - they're created + * per-call and automatically cleaned up after deserialization.
  4. + * + *
  5. Immutability: Jackson's config objects (including handlers) are designed + * to be immutable and reusable. Storing per-call state violates this principle.
  6. + * + *
  7. Configuration vs State: The handler stores configuration (max problems + * limit) while attributes store runtime state (the actual problem list). This + * separation follows Jackson's design patterns.
  8. + *
+ * + *

The handler itself is stateless - it's just a strategy for handling problems. + * The actual collection happens in a bucket passed through context attributes. + * + *

Recoverable errors handled: + *

+ * + *

Default values: Primitives receive zero/false defaults; reference types + * (including boxed primitives) receive {@code null} to avoid masking nullability issues. + * + *

DoS protection: Collection stops when the configured limit (default 100) + * is reached, preventing memory/CPU exhaustion attacks. + * + *

JSON Pointer: Paths are built from parser context following RFC 6901, + * with proper escaping of {@code ~} and {@code /} characters via jackson-core's + * {@link tools.jackson.core.JsonPointer} class. + * + * @since 3.1 + * @see tools.jackson.databind.ObjectReader#problemCollectingReader() + * @see tools.jackson.databind.exc.DeferredBindingException + */ +public class CollectingProblemHandler extends DeserializationProblemHandler { + + /** + * Default maximum number of problems to collect before stopping. + * Prevents memory exhaustion attacks. + */ + private static final int DEFAULT_MAX_PROBLEMS = 100; + + /** + * Unique private key object for the maximum problem limit attribute. + * Using a dedicated object prevents collisions with user attributes. + */ + private static final class MaxProblemsKey { + private MaxProblemsKey() {} // Prevent instantiation + } + public static final Object ATTR_MAX_PROBLEMS = new MaxProblemsKey(); + + /** + * Attribute key for the problem collection bucket. + * Using class object as key (not a string) for type safety. + */ + private static final Object ATTR_KEY = CollectingProblemHandler.class; + + /** + * Retrieves the problem collection bucket from context attributes. + * + * @return Problem bucket, or null if not in collecting mode + */ + public static List getBucket(DeserializationContext ctxt) { + Object attr = ctxt.getAttribute(ATTR_KEY); + return (attr instanceof List) ? (List) attr : null; + } + + /** + * Gets the configured maximum problem limit, or the default if not configured. + */ + private int getMaxProblems(DeserializationContext ctxt) { + Object attr = ctxt.getAttribute(ATTR_MAX_PROBLEMS); + if (attr instanceof Integer) { + return (Integer) attr; + } + return DEFAULT_MAX_PROBLEMS; + } + + /** + * Records a problem in the collection bucket. + * + * @return true if problem was recorded, false if limit reached + */ + private boolean recordProblem(DeserializationContext ctxt, + String message, JavaType targetType, Object rawValue) { + List bucket = getBucket(ctxt); + if (bucket == null) { + return false; // Not in collecting mode + } + + int maxProblems = getMaxProblems(ctxt); + if (bucket.size() >= maxProblems) { + return false; // Limit reached + } + + JsonParser p = ctxt.getParser(); + JsonPointer path = buildJsonPointer(p); + TokenStreamLocation location = safeGetLocation(p); + JsonToken token = safeGetToken(p); + + bucket.add(new CollectedProblem( + path, message, targetType, location, rawValue, token + )); + + return true; + } + + /** + * Safely retrieves the current token location, handling null parser. + */ + private TokenStreamLocation safeGetLocation(JsonParser p) { + try { + return (p != null) ? p.currentTokenLocation() : null; + } catch (Exception e) { + return null; // Defensively handle any errors + } + } + + /** + * Safely retrieves the current token, handling null parser. + */ + private JsonToken safeGetToken(JsonParser p) { + try { + return (p != null) ? p.currentToken() : null; + } catch (Exception e) { + return null; + } + } + + /** + * Builds a JsonPointer from the parser's current context. + * Handles buffered content scenarios where getCurrentName() may return null. + * Returns empty pointer for root-level problems. + * + *

Uses {@link JsonPointer#appendProperty(String)} and + * {@link JsonPointer#appendIndex(int)} from jackson-core, which handle + * RFC 6901 escaping internally ('~' becomes '~0', '/' becomes '~1'). + */ + private JsonPointer buildJsonPointer(JsonParser p) { + if (p == null) { + return JsonPointer.empty(); + } + + TokenStreamContext ctx = p.streamReadContext(); + + // Collect segments from current to root + List segments = new ArrayList<>(); // String (property) or Integer (index) + while (ctx != null) { + if (ctx.inObject() && ctx.currentName() != null) { + segments.add(0, ctx.currentName()); + } else if (ctx.inArray()) { + // getCurrentIndex() may be -1 before consuming first element + int index = ctx.getCurrentIndex(); + if (index >= 0) { + segments.add(0, index); + } + } + ctx = ctx.getParent(); + } + + // Build pointer from root to current using append methods + // (these handle escaping internally via JsonPointer._appendEscaped) + JsonPointer pointer = JsonPointer.empty(); + for (Object segment : segments) { + if (segment instanceof String) { + pointer = pointer.appendProperty((String) segment); + } else { + pointer = pointer.appendIndex((Integer) segment); + } + } + + return pointer; + } + + @Override + public boolean handleUnknownProperty(DeserializationContext ctxt, + JsonParser p, ValueDeserializer deserializer, + Object beanOrClass, String propertyName) throws JacksonException { + + String message = String.format( + "Unknown property '%s' for type %s", + propertyName, + beanOrClass instanceof Class ? + ((Class) beanOrClass).getName() : + beanOrClass.getClass().getName() + ); + + // Store null as rawValue for unknown properties + // (property name is in the path, no need to duplicate) + if (recordProblem(ctxt, message, null, null)) { + p.skipChildren(); // Skip the unknown property value + return true; // Problem handled + } + + return false; // Limit reached, let default handling throw + } + + @Override + public Object handleWeirdKey(DeserializationContext ctxt, + Class rawKeyType, String keyValue, String failureMsg) + throws JacksonException { + + String message = String.format( + "Cannot deserialize Map key '%s' to %s: %s", + keyValue, + rawKeyType.getSimpleName(), + failureMsg + ); + + if (recordProblem(ctxt, message, + ctxt.constructType(rawKeyType), keyValue)) { + // Return NOT_HANDLED instead of null + // Rationale: Some Map implementations (Hashtable, ConcurrentHashMap) + // reject null keys. Safer to let Jackson handle it than risk NPE. + // If null keys are needed, users can provide custom handler. + return NOT_HANDLED; + } + + return NOT_HANDLED; // Limit reached + } + + @Override + public Object handleWeirdStringValue(DeserializationContext ctxt, + Class targetType, String valueToConvert, String failureMsg) + throws JacksonException { + + String message = String.format( + "Cannot deserialize value '%s' to %s: %s", + valueToConvert, + targetType.getSimpleName(), + failureMsg + ); + + if (recordProblem(ctxt, message, + ctxt.constructType(targetType), valueToConvert)) { + // Return sensible default based on target type + return getDefaultValue(targetType); + } + + return NOT_HANDLED; // Limit reached + } + + @Override + public Object handleWeirdNumberValue(DeserializationContext ctxt, + Class targetType, Number valueToConvert, String failureMsg) + throws JacksonException { + + String message = String.format( + "Cannot deserialize number %s to %s: %s", + valueToConvert, + targetType.getSimpleName(), + failureMsg + ); + + if (recordProblem(ctxt, message, + ctxt.constructType(targetType), valueToConvert)) { + return getDefaultValue(targetType); + } + + return NOT_HANDLED; + } + + @Override + public Object handleInstantiationProblem(DeserializationContext ctxt, + Class instClass, Object argument, Throwable t) + throws JacksonException { + + String message = String.format( + "Cannot instantiate %s: %s", + instClass.getSimpleName(), + t.getMessage() + ); + + if (recordProblem(ctxt, message, + ctxt.constructType(instClass), argument)) { + // Only return null if we can safely continue + // For some types, instantiation failure is fatal + if (canReturnNullFor(instClass)) { + return null; + } + } + + return NOT_HANDLED; // Cannot recover + } + + /** + * Returns a sensible default value for the given type to allow + * deserialization to continue. + * + *

IMPORTANT: Only primitives get non-null defaults. Reference types + * (including boxed primitives) get null to avoid masking nullability issues. + */ + private Object getDefaultValue(Class type) { + // Primitives MUST have non-null defaults (cannot be null) + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == double.class) return 0.0; + if (type == float.class) return 0.0f; + if (type == boolean.class) return false; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == char.class) return '\0'; + + // Reference types (including Integer, Long, etc.) get null + // This avoids masking nullability issues in the domain model + return null; + } + + /** + * Checks if it's safe to return null for a given type after + * instantiation failure. + */ + private boolean canReturnNullFor(Class type) { + // Cannot return null for primitives or arrays + if (type.isPrimitive() || type.isArray()) { + return false; + } + // Safe for most reference types + return true; + } +} diff --git a/src/main/java/tools/jackson/databind/exc/CollectedProblem.java b/src/main/java/tools/jackson/databind/exc/CollectedProblem.java new file mode 100644 index 0000000000..7e66051eab --- /dev/null +++ b/src/main/java/tools/jackson/databind/exc/CollectedProblem.java @@ -0,0 +1,108 @@ +package tools.jackson.databind.exc; + +import java.util.Objects; + +import tools.jackson.core.JsonPointer; +import tools.jackson.core.JsonToken; +import tools.jackson.core.TokenStreamLocation; +import tools.jackson.databind.JavaType; + +/** + * Immutable value object capturing details about a single deserialization + * problem encountered during error-collecting mode. + * + *

Contents: Each problem records: + *

+ * + *

Truncation: String values longer than {@value #MAX_RAW_VALUE_LENGTH} + * characters are truncated with "..." suffix to prevent memory issues. + * + *

Unknown properties: For unknown property errors, {@code rawValue} + * is {@code null} since the property name is already in the path. + * + *

Immutability: All instances are immutable and thread-safe. + * + * @since 3.1 + * @see DeferredBindingException#getProblems() + */ +public final class CollectedProblem { + /** + * Maximum length for raw value strings before truncation. + */ + private static final int MAX_RAW_VALUE_LENGTH = 200; + + private final JsonPointer path; + private final String message; + private final JavaType targetType; + private final TokenStreamLocation location; + private final Object rawValue; // @Nullable + private final JsonToken token; // @Nullable + + public CollectedProblem(JsonPointer path, String message, + JavaType targetType, TokenStreamLocation location, + Object rawValue, JsonToken token) { + this.path = Objects.requireNonNull(path, "path"); + this.message = Objects.requireNonNull(message, "message"); + this.targetType = targetType; + this.location = location; + this.rawValue = truncateIfNeeded(rawValue); + this.token = token; + } + + /** + * @return JSON Pointer path to the problematic field (e.g., "/items/1/date"). + * Empty string ("") for root-level problems. + */ + public JsonPointer getPath() { return path; } + + /** + * @return Human-readable error message + */ + public String getMessage() { return message; } + + /** + * @return Expected Java type for the field (may be null) + */ + public JavaType getTargetType() { return targetType; } + + /** + * @return Location in source JSON where problem occurred (may be null) + */ + public TokenStreamLocation getLocation() { return location; } + + /** + * @return Raw value from JSON that caused the problem (may be null or truncated). + * For unknown properties, this is null; use the path to identify the property name. + */ + public Object getRawValue() { return rawValue; } + + /** + * @return JSON token type at the error location (may be null) + */ + public JsonToken getToken() { return token; } + + private static Object truncateIfNeeded(Object value) { + if (value instanceof String) { + String s = (String) value; + if (s.length() > MAX_RAW_VALUE_LENGTH) { + return s.substring(0, MAX_RAW_VALUE_LENGTH - 3) + "..."; + } + } + return value; + } + + @Override + public String toString() { + return String.format("CollectedProblem[path=%s, message=%s, targetType=%s]", + path, message, targetType); + } +} diff --git a/src/main/java/tools/jackson/databind/exc/DeferredBindingException.java b/src/main/java/tools/jackson/databind/exc/DeferredBindingException.java new file mode 100644 index 0000000000..7847506ac5 --- /dev/null +++ b/src/main/java/tools/jackson/databind/exc/DeferredBindingException.java @@ -0,0 +1,110 @@ +package tools.jackson.databind.exc; + +import java.util.Collections; +import java.util.List; + +import tools.jackson.core.JsonParser; +import tools.jackson.databind.DatabindException; + +/** + * Exception that aggregates multiple recoverable deserialization problems + * encountered during problem-collecting mode. + * + *

Usage: This exception is thrown by + * {@link tools.jackson.databind.ObjectReader#readValueCollectingProblems ObjectReader.readValueCollectingProblems(...)} + * when one or more recoverable problems were collected during deserialization. + * Enable problem collection via {@link tools.jackson.databind.ObjectReader#problemCollectingReader()}. + * + *

Problem access: Each problem is captured as a {@link CollectedProblem} + * containing the JSON Pointer path, error message, location, target type, raw value, and token. + * Access problems via {@link #getProblems()}. + * + *

Limit handling: When the configured problem limit is reached, collection + * stops and {@link #isLimitReached()} returns {@code true}. This indicates additional + * problems may exist beyond those collected. + * + *

Message formatting: The exception message shows: + *

+ * + *

Example: + *

{@code
+ * try {
+ *     MyBean bean = reader.problemCollectingReader()
+ *                         .readValueCollectingProblems(json);
+ * } catch (DeferredBindingException e) {
+ *     for (CollectedProblem p : e.getProblems()) {
+ *         System.err.println("Error at " + p.getPath() + ": " + p.getMessage());
+ *     }
+ * }
+ * }
+ * + * @since 3.1 + */ +public class DeferredBindingException extends DatabindException { + private static final long serialVersionUID = 1L; + + private final List problems; + private final boolean limitReached; + + public DeferredBindingException(JsonParser p, + List problems, + boolean limitReached) { + super(p, formatMessage(problems, limitReached)); + this.problems = Collections.unmodifiableList(problems); + this.limitReached = limitReached; + } + + /** + * @return Unmodifiable list of all collected problems + */ + public List getProblems() { + return problems; + } + + /** + * @return Number of problems collected + */ + public int getProblemCount() { + return problems.size(); + } + + /** + * @return true if error collection stopped due to reaching the configured limit + */ + public boolean isLimitReached() { + return limitReached; + } + + private static String formatMessage(List problems, boolean limitReached) { + int count = problems.size(); + if (count == 1) { + return "1 deserialization problem: " + problems.get(0).getMessage(); + } + + String limitNote = limitReached ? " (limit reached; more errors may exist)" : ""; + return String.format( + "%d deserialization problems%s (showing first 5):%n%s", + count, + limitNote, + formatProblems(problems) + ); + } + + private static String formatProblems(List problems) { + StringBuilder sb = new StringBuilder(); + int limit = Math.min(5, problems.size()); + for (int i = 0; i < limit; i++) { + CollectedProblem p = problems.get(i); + sb.append(String.format(" [%d] at %s: %s%n", + i + 1, p.getPath(), p.getMessage())); + } + if (problems.size() > 5) { + sb.append(String.format(" ... and %d more", problems.size() - 5)); + } + return sb.toString(); + } +} diff --git a/src/test/java/tools/jackson/databind/deser/CollectingErrorsTest.java b/src/test/java/tools/jackson/databind/deser/CollectingErrorsTest.java new file mode 100644 index 0000000000..0e7cb86edb --- /dev/null +++ b/src/test/java/tools/jackson/databind/deser/CollectingErrorsTest.java @@ -0,0 +1,878 @@ +package tools.jackson.databind.deser; + +import java.io.File; +import java.io.InputStream; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import tools.jackson.databind.*; +import tools.jackson.databind.exc.DeferredBindingException; +import tools.jackson.databind.testutil.DatabindTestUtil; + +import static org.assertj.core.api.Assertions.*; + +/** + * Tests for error-collecting deserialization feature (issue #1196). + * Verifies opt-in per-call error collection via ObjectReader.problemCollectingReader(). + */ +public class CollectingErrorsTest extends DatabindTestUtil +{ + private final ObjectMapper MAPPER = newJsonMapper(); + + /* + /********************************************************************** + /* Test POJOs + /********************************************************************** + */ + + static class Person { + public String name; + public int age; + public boolean active; + } + + static class Order { + public int orderId; + public List items; + } + + static class Item { + public String sku; + public double price; + public int quantity; + } + + static class TypedData { + public int intValue; + public long longValue; + public double doubleValue; + public float floatValue; + public boolean boolValue; + public Integer boxedInt; + public String stringValue; + } + + static class JsonPointerTestBean { + public String normalField; + public String fieldWithSlash; + public String fieldWithTilde; + public String fieldWithBoth; + } + + /* + /********************************************************************** + /* Helper methods + /********************************************************************** + */ + + /** + * Helper to reduce boilerplate: captures DeferredBindingException and returns it. + * Use with AssertJ for cleaner assertions. + */ + private DeferredBindingException expectDeferredBinding(ObjectReader reader, String json) { + return catchThrowableOfType( + DeferredBindingException.class, + () -> reader.readValueCollectingProblems(json) + ); + } + + /** + * Overload for byte[] input + */ + private DeferredBindingException expectDeferredBinding(ObjectReader reader, byte[] json) { + return catchThrowableOfType( + DeferredBindingException.class, + () -> reader.readValueCollectingProblems(json) + ); + } + + /** + * Overload for File input + */ + private DeferredBindingException expectDeferredBinding(ObjectReader reader, File json) { + return catchThrowableOfType( + DeferredBindingException.class, + () -> reader.readValueCollectingProblems(json) + ); + } + + /** + * Overload for InputStream input + */ + private DeferredBindingException expectDeferredBinding(ObjectReader reader, InputStream json) { + return catchThrowableOfType( + DeferredBindingException.class, + () -> reader.readValueCollectingProblems(json) + ); + } + + /** + * Overload for Reader input + */ + private DeferredBindingException expectDeferredBinding(ObjectReader reader, Reader json) { + return catchThrowableOfType( + DeferredBindingException.class, + () -> reader.readValueCollectingProblems(json) + ); + } + + /** + * Helper to build JSON with specified number of invalid order items. + * Used for testing limit behavior and hard failures. + */ + private String buildInvalidOrderJson(int itemCount) { + StringBuilder json = new StringBuilder("{\"items\":["); + for (int i = 0; i < itemCount; i++) { + if (i > 0) json.append(","); + json.append("{\"price\":\"invalid").append(i).append("\"}"); + } + json.append("]}"); + return json.toString(); + } + + /* + /********************************************************************** + /* Test: Default behavior (fail-fast unchanged) + /********************************************************************** + */ + + @Nested + @DisplayName("Default fail-fast behavior") + class DefaultBehaviorTests { + + @Test + @DisplayName("should fail fast by default when error encountered") + void failFastDefault() { + // setup + String json = "{\"name\":\"John\",\"age\":\"not-a-number\"}"; + + // when/then + assertThatThrownBy(() -> MAPPER.readValue(json, Person.class)) + .isInstanceOf(DatabindException.class) + .hasMessageContaining("not-a-number"); + } + + @Test + @DisplayName("should fail fast when using regular readValue even after problemCollectingReader") + void failFastAfterCollectErrors() { + // setup + String json = "{\"name\":\"John\",\"age\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when/then - using regular readValue, not readValueCollectingProblems + assertThatThrownBy(() -> reader.readValue(json)) + .isInstanceOf(DatabindException.class); + } + } + + /* + /********************************************************************** + /* Test: Per-call bucket isolation + /********************************************************************** + */ + + @Nested + @DisplayName("Per-call bucket isolation") + class BucketIsolationTests { + + @Test + @DisplayName("should isolate errors between successive calls") + void successiveCalls() { + // setup + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + String json1 = "{\"name\":\"Alice\",\"age\":\"invalid1\"}"; + String json2 = "{\"name\":\"Bob\",\"age\":\"invalid2\"}"; + + // when + DeferredBindingException ex1 = expectDeferredBinding(reader, json1); + DeferredBindingException ex2 = expectDeferredBinding(reader, json2); + + // then + assertThat(ex1).isNotNull(); + assertThat(ex2).isNotNull(); + assertThat(ex1.getProblems()).hasSize(1); + assertThat(ex2.getProblems()).hasSize(1); + assertThat(ex1.getProblems().get(0).getRawValue()).isEqualTo("invalid1"); + assertThat(ex2.getProblems().get(0).getRawValue()).isEqualTo("invalid2"); + } + + @Test + @DisplayName("should isolate errors in concurrent calls") + void concurrentCalls() throws Exception { + // setup + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + List exceptions = + Collections.synchronizedList(new ArrayList<>()); + List unexpectedErrors = + Collections.synchronizedList(new ArrayList<>()); + + // when + try { + for (int i = 0; i < threadCount; i++) { + final int index = i; + executor.submit(() -> { + try { + String json = String.format("{\"name\":\"User%d\",\"age\":\"invalid%d\"}", + index, index); + reader.readValueCollectingProblems(json); + unexpectedErrors.add(new AssertionError("Should have thrown DeferredBindingException")); + } catch (DeferredBindingException e) { + exceptions.add(e); + successCount.incrementAndGet(); + } catch (Throwable t) { + unexpectedErrors.add(t); + } finally { + latch.countDown(); + } + }); + } + + // Wait for all threads with assertion + assertThat(latch.await(5, TimeUnit.SECONDS)) + .as("All threads should complete within timeout") + .isTrue(); + + } finally { + executor.shutdown(); + try { + assertThat(executor.awaitTermination(2, TimeUnit.SECONDS)) + .as("Executor should terminate within timeout") + .isTrue(); + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + + // then + assertThat(unexpectedErrors) + .as("No unexpected exceptions should occur") + .isEmpty(); + assertThat(successCount.get()).isEqualTo(threadCount); + + // Synchronize iteration per JDK contract for Collections.synchronizedList() + synchronized (exceptions) { + assertThat(exceptions).hasSize(threadCount); + + // Verify each exception has exactly 1 problem and collect all raw values + List rawValues = new ArrayList<>(); + for (DeferredBindingException ex : exceptions) { + assertThat(ex.getProblems()).hasSize(1); + String rawValue = (String) ex.getProblems().get(0).getRawValue(); + rawValues.add(rawValue); + } + + // Verify we have exactly the unique values from each thread (no bucket sharing) + assertThat(rawValues) + .as("Each thread should have its own isolated error bucket") + .containsExactlyInAnyOrder( + "invalid0", "invalid1", "invalid2", "invalid3", "invalid4", + "invalid5", "invalid6", "invalid7", "invalid8", "invalid9" + ); + } + } + } + + /* + /********************************************************************** + /* Test: JSON Pointer escaping (RFC 6901) + /********************************************************************** + */ + + @Nested + @DisplayName("JSON Pointer escaping (RFC 6901)") + class JsonPointerEscapingTests { + + @Test + @DisplayName("should escape tilde in property names") + void escapeTilde() { + // setup + String json = "{\"field~name\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class) + .problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + // Tilde should be escaped as ~0 + assertThat(ex.getProblems().get(0).getPath().toString()) + .isEqualTo("/field~0name"); + } + + @Test + @DisplayName("should escape slash in property names") + void escapeSlash() { + // setup + String json = "{\"field/name\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class) + .problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + // Slash should be escaped as ~1 + assertThat(ex.getProblems().get(0).getPath().toString()) + .isEqualTo("/field~1name"); + } + + @Test + @DisplayName("should escape both tilde and slash correctly") + void escapeBoth() { + // setup + String json = "{\"field~/name\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(JsonPointerTestBean.class) + .problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + // Must escape ~ first (to ~0), then / (to ~1) + assertThat(ex.getProblems().get(0).getPath().toString()) + .isEqualTo("/field~0~1name"); + } + + @Test + @DisplayName("should handle array indices in pointer") + void arrayIndices() { + // setup + String json = "{\"orderId\":123,\"items\":[" + + "{\"sku\":\"ABC\",\"price\":\"invalid\",\"quantity\":5}," + + "{\"sku\":\"DEF\",\"price\":99.99,\"quantity\":\"bad\"}" + + "]}"; + ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(2); + assertThat(ex.getProblems().get(0).getPath().toString()) + .contains("/items/0/price"); + assertThat(ex.getProblems().get(1).getPath().toString()) + .contains("/items/1/quantity"); + } + } + + /* + /********************************************************************** + /* Test: Limit reached behavior + /********************************************************************** + */ + + @Nested + @DisplayName("Limit reached behavior") + class LimitReachedTests { + + @Test + @DisplayName("should stop collecting when default limit reached") + void defaultLimit() { + // setup - create JSON with 101 errors (default limit is 100) + String json = buildInvalidOrderJson(101); + ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(); + + // when + Throwable thrown = catchThrowable(() -> reader.readValueCollectingProblems(json)); + + // then - should get hard failure with collected problems in suppressed + assertThat(thrown).isInstanceOf(DatabindException.class); + Throwable[] suppressed = thrown.getSuppressed(); + assertThat(suppressed).hasSizeGreaterThanOrEqualTo(1); + + DeferredBindingException deferred = null; + for (Throwable s : suppressed) { + if (s instanceof DeferredBindingException) { + deferred = (DeferredBindingException) s; + break; + } + } + + assertThat(deferred).isNotNull(); + assertThat(deferred.getProblems()).hasSize(100); // Stopped at limit + assertThat(deferred.isLimitReached()).isTrue(); + assertThat(deferred.getMessage()).contains("limit reached"); + } + + @Test + @DisplayName("should respect custom limit") + void customLimit() { + // setup + String json = buildInvalidOrderJson(20); + ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(10); + + // when + Throwable thrown = catchThrowable(() -> reader.readValueCollectingProblems(json)); + + // then + assertThat(thrown).isInstanceOf(DatabindException.class); + Throwable[] suppressed = thrown.getSuppressed(); + assertThat(suppressed).hasSizeGreaterThanOrEqualTo(1); + + DeferredBindingException deferred = null; + for (Throwable s : suppressed) { + if (s instanceof DeferredBindingException) { + deferred = (DeferredBindingException) s; + break; + } + } + + assertThat(deferred).isNotNull(); + assertThat(deferred.getProblems()).hasSize(10); // Custom limit + assertThat(deferred.isLimitReached()).isTrue(); + } + + @Test + @DisplayName("should not set limit reached when under limit") + void underLimit() { + // setup + String json = "{\"name\":\"John\",\"age\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(100); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.isLimitReached()).isFalse(); + assertThat(ex.getMessage()).doesNotContain("limit reached"); + } + } + + /* + /********************************************************************** + /* Test: Unknown property handling + /********************************************************************** + */ + + @Nested + @DisplayName("Unknown property handling") + class UnknownPropertyTests { + + @Test + @DisplayName("should collect unknown property errors when FAIL_ON_UNKNOWN_PROPERTIES enabled") + void unknownProperty() { + // setup + String json = "{\"name\":\"Alice\",\"unknownField\":\"value\",\"age\":30}"; + ObjectReader reader = MAPPER.readerFor(Person.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then - unknown property error is collected + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getMessage()) + .contains("Unknown property 'unknownField'"); + } + + @Test + @DisplayName("should skip unknown property children") + void skipUnknownChildren() { + // setup + String json = "{\"name\":\"Bob\",\"unknownObject\":{\"nested\":\"value\"},\"age\":25}"; + ObjectReader reader = MAPPER.readerFor(Person.class) + .with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getMessage()) + .contains("Unknown property 'unknownObject'"); + } + } + + /* + /********************************************************************** + /* Test: Default value policy (primitives vs references) + /********************************************************************** + */ + + @Nested + @DisplayName("Default value policy") + class DefaultValuePolicyTests { + + @Test + @DisplayName("should collect error for primitive int coercion") + void primitiveInt() { + // setup + String json = "{\"intValue\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then - error collected with default value used + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getRawValue()).isEqualTo("invalid"); + } + + @Test + @DisplayName("should collect error for primitive long coercion") + void primitiveLong() { + // setup + String json = "{\"longValue\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + } + + @Test + @DisplayName("should collect error for primitive double coercion") + void primitiveDouble() { + // setup + String json = "{\"doubleValue\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + } + + @Test + @DisplayName("should collect error for primitive boolean coercion") + void primitiveBoolean() { + // setup + String json = "{\"boolValue\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + } + + @Test + @DisplayName("should collect error for boxed Integer coercion") + void boxedInteger() { + // setup + String json = "{\"boxedInt\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then - error collected for reference type + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + } + + @Test + @DisplayName("should handle multiple type coercion errors") + void multipleTypeErrors() { + // setup + String json = "{\"intValue\":\"bad1\",\"longValue\":\"bad2\",\"doubleValue\":\"bad3\"}"; + ObjectReader reader = MAPPER.readerFor(TypedData.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(3); + assertThat(ex.getProblems()) + .extracting(p -> p.getPath().toString()) + .containsExactlyInAnyOrder("/intValue", "/longValue", "/doubleValue"); + } + } + + /* + /********************************************************************** + /* Test: Root-level problems + /********************************************************************** + */ + + @Nested + @DisplayName("Root-level problems") + class RootLevelTests { + + @Test + @DisplayName("should not collect root-level type mismatches") + void rootLevelTypeMismatch() { + // setup - root value is invalid for Person (non-recoverable) + String json = "\"not-an-object\""; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when/then - root-level type mismatches are non-recoverable + // They occur before property deserialization, so handler is never invoked + assertThatThrownBy(() -> reader.readValueCollectingProblems(json)) + .isInstanceOf(DatabindException.class) + .hasMessageContaining("Cannot construct instance") + .satisfies(ex -> { + // Verify no problems were collected (root errors are non-recoverable) + assertThat(ex.getSuppressed()).isEmpty(); + }); + } + + @Test + @DisplayName("should format property paths correctly without double slashes") + void propertyPathFormatting() { + // setup + String json = "{\"age\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + String pointer = ex.getProblems().get(0).getPath().toString(); + assertThat(pointer).isEqualTo("/age"); + assertThat(pointer).doesNotContain("//"); // No double slashes + } + } + + /* + /********************************************************************** + /* Test: Hard failure with suppressed exceptions + /********************************************************************** + */ + + @Nested + @DisplayName("Hard failure with suppressed exceptions") + class HardFailureTests { + + @Test + @DisplayName("should attach collected problems as suppressed on hard failure") + void suppressedProblems() { + // setup - create JSON with 101 errors to trigger limit and hard failure + // (shares scenario with defaultLimit test but focuses on suppressed exception mechanics) + String json = buildInvalidOrderJson(101); + ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(); + + // when + Throwable thrown = catchThrowable(() -> reader.readValueCollectingProblems(json)); + + // then - verify suppressed exception attachment mechanism + assertThat(thrown).isInstanceOf(DatabindException.class); + + DeferredBindingException deferred = null; + for (Throwable s : thrown.getSuppressed()) { + if (s instanceof DeferredBindingException) { + deferred = (DeferredBindingException) s; + break; + } + } + + assertThat(deferred) + .as("Collected problems should be attached as suppressed DeferredBindingException") + .isNotNull(); + assertThat(deferred.getProblems()).hasSize(100); + } + } + + /* + /********************************************************************** + /* Test: Message formatting + /********************************************************************** + */ + + @Nested + @DisplayName("Message formatting") + class MessageFormattingTests { + + @Test + @DisplayName("should format single error message") + void singleError() { + // setup + String json = "{\"age\":\"invalid\"}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getMessage()).contains("1 deserialization problem"); + } + + @Test + @DisplayName("should format multiple errors with first 5 shown") + void multipleErrors() { + // setup + String json = buildInvalidOrderJson(10); + ObjectReader reader = MAPPER.readerFor(Order.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, json); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getMessage()) + .contains("10 deserialization problems") + .contains("showing first 5") + .contains("... and 5 more"); + } + } + + /* + /********************************************************************** + /* Test: Edge cases + /********************************************************************** + */ + + @Nested + @DisplayName("Edge cases") + class EdgeCaseTests { + + @Test + @DisplayName("should validate positive maxProblems") + void validateMaxProblems() { + // when/then + assertThatThrownBy(() -> MAPPER.readerFor(Person.class).problemCollectingReader(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("maxProblems must be positive"); + + assertThatThrownBy(() -> MAPPER.readerFor(Person.class).problemCollectingReader(-1)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should handle empty JSON") + void emptyJson() { + // setup + String json = "{}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + Person result = reader.readValueCollectingProblems(json); + + // then + assertThat(result).isNotNull(); + assertThat(result.name).isNull(); + assertThat(result.age).isEqualTo(0); + } + + @Test + @DisplayName("should handle null parser gracefully") + void nullParser() { + // setup + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when/then + assertThatThrownBy(() -> reader.readValueCollectingProblems((String) null)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("should collect errors via byte[] overload") + void collectFromByteArray() { + // setup + String jsonString = "{\"name\":\"Alice\",\"age\":\"invalid\"}"; + byte[] jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8); + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, jsonBytes); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age"); + } + + @Test + @DisplayName("should collect errors via File overload") + void collectFromFile() throws Exception { + // setup + File tempFile = File.createTempFile("test", ".json"); + try { + java.nio.file.Files.writeString(tempFile.toPath(), + "{\"name\":\"Bob\",\"age\":\"notANumber\"}"); + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex = expectDeferredBinding(reader, tempFile); + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getMessage()) + .contains("notANumber"); + } finally { + java.nio.file.Files.deleteIfExists(tempFile.toPath()); + } + } + + @Test + @DisplayName("should collect errors via InputStream overload") + void collectFromInputStream() throws Exception { + // setup + String json = "{\"name\":\"Charlie\",\"age\":\"bad\"}"; + ObjectReader reader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex; + try (InputStream input = new java.io.ByteArrayInputStream( + json.getBytes(StandardCharsets.UTF_8))) { + ex = expectDeferredBinding(reader, input); + } + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age"); + } + + @Test + @DisplayName("should collect errors via Reader overload") + void collectFromReader() throws Exception { + // setup + String json = "{\"name\":\"Diana\",\"age\":\"invalid\"}"; + ObjectReader objectReader = MAPPER.readerFor(Person.class).problemCollectingReader(); + + // when + DeferredBindingException ex; + try (Reader reader = new java.io.StringReader(json)) { + ex = expectDeferredBinding(objectReader, reader); + } + + // then + assertThat(ex).isNotNull(); + assertThat(ex.getProblems()).hasSize(1); + assertThat(ex.getProblems().get(0).getPath().toString()).isEqualTo("/age"); + } + } +}