|
1 | 1 | package io.audd.internal; |
2 | 2 |
|
| 3 | +import com.fasterxml.jackson.core.JsonParser; |
| 4 | +import com.fasterxml.jackson.core.JsonToken; |
| 5 | +import com.fasterxml.jackson.databind.DeserializationContext; |
3 | 6 | import com.fasterxml.jackson.databind.DeserializationFeature; |
| 7 | +import com.fasterxml.jackson.databind.JavaType; |
4 | 8 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 9 | +import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler; |
| 10 | + |
| 11 | +import java.io.IOException; |
5 | 12 |
|
6 | 13 | /** |
7 | 14 | * Shared, leniently-configured {@link ObjectMapper} for all response parsing. |
@@ -42,15 +49,97 @@ public static ObjectMapper mapper() { |
42 | 49 | * </ul> |
43 | 50 | */ |
44 | 51 | public static ObjectMapper newLenientMapper() { |
45 | | - return new ObjectMapper() |
| 52 | + ObjectMapper mapper = new ObjectMapper() |
46 | 53 | .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) |
47 | 54 | .configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false) |
48 | 55 | .configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, false) |
| 56 | + .configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false) |
49 | 57 | .configure(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS, false) |
50 | 58 | .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true) |
51 | 59 | .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) |
52 | 60 | .configure(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS, true) |
53 | 61 | .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true) |
54 | 62 | .configure(DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT, true); |
| 63 | + mapper.addHandler(lenientProblemHandler()); |
| 64 | + return mapper; |
| 65 | + } |
| 66 | + |
| 67 | + /** |
| 68 | + * A last line of defence for shape drift that the feature flags above don't |
| 69 | + * cover: a field typed as a scalar arriving as an object/array (e.g. |
| 70 | + * {@code spotify.name} as an object), a numeric field arriving as a |
| 71 | + * non-numeric string ({@code score}/{@code audio_id} as {@code "n/a"}), a |
| 72 | + * provider block arriving as a bare string, or an object key that can't be |
| 73 | + * coerced. In every such case we skip the offending value and degrade the |
| 74 | + * field to {@code null} instead of letting the whole response fail to |
| 75 | + * decode. Only genuinely undecodable JSON, transport errors, {@code |
| 76 | + * status=error} bodies, and caller-input errors are allowed to surface. |
| 77 | + */ |
| 78 | + private static DeserializationProblemHandler lenientProblemHandler() { |
| 79 | + return new DeserializationProblemHandler() { |
| 80 | + /** Scalar-typed field arriving as an object/array (or any wrong token). */ |
| 81 | + @Override |
| 82 | + public Object handleUnexpectedToken(DeserializationContext ctxt, JavaType targetType, |
| 83 | + JsonToken t, JsonParser p, String failureMsg) throws IOException { |
| 84 | + skipCurrentValue(p, t); |
| 85 | + return null; |
| 86 | + } |
| 87 | + |
| 88 | + /** Numeric field arriving as a non-numeric string ({@code "n/a"}). */ |
| 89 | + @Override |
| 90 | + public Object handleWeirdStringValue(DeserializationContext ctxt, Class<?> targetType, |
| 91 | + String valueToConvert, String failureMsg) { |
| 92 | + return null; |
| 93 | + } |
| 94 | + |
| 95 | + /** String field arriving as a number that can't be coerced, etc. */ |
| 96 | + @Override |
| 97 | + public Object handleWeirdNativeValue(DeserializationContext ctxt, JavaType targetType, |
| 98 | + Object valueToConvert, JsonParser p) { |
| 99 | + return null; |
| 100 | + } |
| 101 | + |
| 102 | + /** A number that overflows / can't map to the target numeric type. */ |
| 103 | + @Override |
| 104 | + public Object handleWeirdNumberValue(DeserializationContext ctxt, Class<?> targetType, |
| 105 | + Number valueToConvert, String failureMsg) { |
| 106 | + return null; |
| 107 | + } |
| 108 | + |
| 109 | + /** A map key that can't be coerced to the key type — drop that entry. */ |
| 110 | + @Override |
| 111 | + public Object handleWeirdKey(DeserializationContext ctxt, Class<?> rawKeyType, |
| 112 | + String keyValue, String failureMsg) { |
| 113 | + return null; |
| 114 | + } |
| 115 | + |
| 116 | + /** Concrete type has no usable constructor for the incoming shape. */ |
| 117 | + @Override |
| 118 | + public Object handleMissingInstantiator(DeserializationContext ctxt, Class<?> instClass, |
| 119 | + com.fasterxml.jackson.databind.deser.ValueInstantiator valueInsta, |
| 120 | + JsonParser p, String msg) throws IOException { |
| 121 | + skipCurrentValue(p, p.currentToken()); |
| 122 | + return null; |
| 123 | + } |
| 124 | + |
| 125 | + /** Construction of a value threw — degrade rather than propagate. */ |
| 126 | + @Override |
| 127 | + public Object handleInstantiationProblem(DeserializationContext ctxt, Class<?> instClass, |
| 128 | + Object argument, Throwable t) { |
| 129 | + return null; |
| 130 | + } |
| 131 | + }; |
| 132 | + } |
| 133 | + |
| 134 | + /** |
| 135 | + * Advance the parser past the value currently under the cursor so decoding |
| 136 | + * of sibling fields can continue. For a START_OBJECT/START_ARRAY this skips |
| 137 | + * the whole subtree; for a scalar the cursor already sits on the value and |
| 138 | + * needs no further advance. |
| 139 | + */ |
| 140 | + private static void skipCurrentValue(JsonParser p, JsonToken t) throws IOException { |
| 141 | + if (t == JsonToken.START_OBJECT || t == JsonToken.START_ARRAY) { |
| 142 | + p.skipChildren(); |
| 143 | + } |
55 | 144 | } |
56 | 145 | } |
0 commit comments