22
33import com .fasterxml .jackson .core .JsonParser ;
44import com .fasterxml .jackson .core .JsonToken ;
5+ import com .fasterxml .jackson .databind .BeanDescription ;
6+ import com .fasterxml .jackson .databind .DeserializationConfig ;
57import com .fasterxml .jackson .databind .DeserializationContext ;
68import com .fasterxml .jackson .databind .DeserializationFeature ;
79import com .fasterxml .jackson .databind .JavaType ;
10+ import com .fasterxml .jackson .databind .JsonDeserializer ;
811import com .fasterxml .jackson .databind .ObjectMapper ;
12+ import com .fasterxml .jackson .databind .deser .BeanDeserializerModifier ;
913import com .fasterxml .jackson .databind .deser .DeserializationProblemHandler ;
14+ import com .fasterxml .jackson .databind .module .SimpleModule ;
1015
1116import java .io .IOException ;
17+ import java .util .Locale ;
1218
1319/**
1420 * Shared, leniently-configured {@link ObjectMapper} for all response parsing.
1824 * example, legitimately returns matches with no {@code score} (and no
1925 * {@code isrc}/{@code upc}/{@code label}). The mapper here is configured so
2026 * that missing, unknown, and null-for-primitive fields all degrade to
21- * sensible defaults instead of throwing.</p>
27+ * sensible defaults instead of throwing, and so that a wrong-but-convertible
28+ * scalar is coerced into the declared type instead of being dropped.</p>
2229 *
2330 * <p>This does not weaken the error contract: a {@code status=error} body is
2431 * still turned into a typed exception before any model is decoded, and a body
@@ -45,7 +52,11 @@ public static ObjectMapper mapper() {
4552 * <li>a missing {@code @JsonCreator} property is allowed;</li>
4653 * <li>scalars accepted for single-element arrays and vice versa, so a
4754 * field that is sometimes an object and sometimes a list still
48- * decodes.</li>
55+ * decodes;</li>
56+ * <li>a wrong-but-convertible scalar (a number for a String field, a
57+ * numeric string for an int field, and so on) is coerced into the
58+ * declared type; only the genuinely unconvertible degrades to
59+ * {@code null}.</li>
4960 * </ul>
5061 */
5162 public static ObjectMapper newLenientMapper () {
@@ -60,35 +71,175 @@ public static ObjectMapper newLenientMapper() {
6071 .configure (DeserializationFeature .UNWRAP_SINGLE_VALUE_ARRAYS , true )
6172 .configure (DeserializationFeature .ACCEPT_EMPTY_STRING_AS_NULL_OBJECT , true )
6273 .configure (DeserializationFeature .ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT , true );
74+ // An empty string for a boolean field should reach the whitelist below
75+ // (where "" maps to false) instead of Jackson's default empty-string
76+ // -> null. Route it through the coercion/handler path.
77+ mapper .coercionConfigFor (com .fasterxml .jackson .databind .type .LogicalType .Boolean )
78+ .setCoercion (com .fasterxml .jackson .databind .cfg .CoercionInputShape .EmptyString ,
79+ com .fasterxml .jackson .databind .cfg .CoercionAction .TryConvert );
80+ mapper .registerModule (scalarShapeGuardModule ());
6381 mapper .addHandler (lenientProblemHandler ());
6482 return mapper ;
6583 }
6684
85+ /**
86+ * A module that wraps every scalar (number/boolean) value deserializer so a
87+ * container arriving where a scalar is expected is skipped to {@code null}
88+ * rather than crashing.
89+ *
90+ * <p>Jackson's stock number deserializers try to coerce a
91+ * {@code START_OBJECT}/{@code START_ARRAY} through a from-string path whose
92+ * input is {@code null}, which throws before {@link
93+ * DeserializationProblemHandler#handleUnexpectedToken} ever runs. Guarding
94+ * here — ahead of the stock logic — keeps the container-for-scalar case on
95+ * the same degrade-to-{@code null} contract as every other shape drift.</p>
96+ */
97+ private static SimpleModule scalarShapeGuardModule () {
98+ SimpleModule module = new SimpleModule ("auddScalarShapeGuard" );
99+ module .setDeserializerModifier (new BeanDeserializerModifier () {
100+ @ Override
101+ public JsonDeserializer <?> modifyDeserializer (DeserializationConfig config , BeanDescription beanDesc ,
102+ JsonDeserializer <?> deserializer ) {
103+ Class <?> handled = deserializer .handledType ();
104+ if (handled == null ) handled = beanDesc .getBeanClass ();
105+ if (isGuardedScalar (handled )) {
106+ return new ScalarShapeGuardDeserializer (deserializer );
107+ }
108+ return deserializer ;
109+ }
110+ });
111+ return module ;
112+ }
113+
114+ /** Boxed scalar types whose deserializer we wrap. Primitives share these deserializers. */
115+ private static boolean isGuardedScalar (Class <?> c ) {
116+ return c == Integer .class || c == Long .class || c == Short .class || c == Byte .class
117+ || c == Double .class || c == Float .class || c == Boolean .class ;
118+ }
119+
120+ /**
121+ * Delegates to the stock scalar deserializer, but if the current token is a
122+ * {@code START_OBJECT}/{@code START_ARRAY} (a container where a scalar is
123+ * expected), it skips the subtree and yields {@code null}. Every other token
124+ * — including all the wrong-but-convertible scalars — flows to the delegate
125+ * and its coercion/handler path unchanged.
126+ */
127+ private static final class ScalarShapeGuardDeserializer extends JsonDeserializer <Object > {
128+ private final JsonDeserializer <?> delegate ;
129+
130+ ScalarShapeGuardDeserializer (JsonDeserializer <?> delegate ) {
131+ this .delegate = delegate ;
132+ }
133+
134+ @ Override
135+ public Object deserialize (JsonParser p , DeserializationContext ctxt ) throws IOException {
136+ JsonToken t = p .currentToken ();
137+ if (t == JsonToken .START_OBJECT || t == JsonToken .START_ARRAY ) {
138+ p .skipChildren ();
139+ return null ;
140+ }
141+ return delegate .deserialize (p , ctxt );
142+ }
143+
144+ @ Override
145+ public Object getNullValue (DeserializationContext ctxt ) throws com .fasterxml .jackson .databind .JsonMappingException {
146+ return delegate .getNullValue (ctxt );
147+ }
148+ }
149+
67150 /**
68151 * 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.
152+ * cover. Two jobs:
153+ *
154+ * <ol>
155+ * <li><b>Coerce convertible wrong-typed scalars.</b> A field that arrives
156+ * as a scalar of the wrong-but-convertible JSON type is rendered into
157+ * the declared type rather than dropped:
158+ * <ul>
159+ * <li>expecting a String: a JSON number/boolean becomes its rendered
160+ * text ({@code 85 -> "85"}, {@code true -> "true"});</li>
161+ * <li>expecting an int/long: a whole-number string parses, a
162+ * float/double truncates, a boolean maps to {@code 0}/{@code 1};</li>
163+ * <li>expecting a float/double: a numeric string parses and an int
164+ * converts;</li>
165+ * <li>expecting a boolean: a number maps by {@code value != 0} and a
166+ * string maps through a fixed whitelist
167+ * ({@code true/1/yes/on -> true}, {@code false/0/no/off/"" -> false}).</li>
168+ * </ul>
169+ * Numeric-string parsing is strict over the whole trimmed string, so
170+ * {@code "85abc"}, {@code "NaN"} and {@code "Infinity"} do not slip
171+ * through as numbers.</li>
172+ * <li><b>Degrade the genuinely unconvertible to {@code null}.</b> A scalar
173+ * field arriving as an object/array, a numeric field carrying a
174+ * non-numeric string ({@code "n/a"}), a boolean field carrying an
175+ * out-of-whitelist string ({@code "maybe"}), a provider block arriving
176+ * as a bare string, or an uncoercible map key are all skipped and the
177+ * field left {@code null} — never a garbage {@code 0}/{@code false}.
178+ * Only genuinely undecodable JSON, transport errors, {@code
179+ * status=error} bodies, and caller-input errors are allowed to
180+ * surface.</li>
181+ * </ol>
182+ *
183+ * <p>All of this lives only on the failure path; the fast path where the
184+ * wire type already matches the model is untouched.</p>
77185 */
78186 private static DeserializationProblemHandler lenientProblemHandler () {
79187 return new DeserializationProblemHandler () {
80- /** Scalar-typed field arriving as an object/array (or any wrong token). */
188+ /**
189+ * A scalar-typed field arriving as an unexpected token. Coerce the
190+ * convertible cross-scalar cases (number/boolean into a String
191+ * field; boolean into a numeric field); skip objects, arrays, and
192+ * anything else to {@code null}.
193+ */
81194 @ Override
82195 public Object handleUnexpectedToken (DeserializationContext ctxt , JavaType targetType ,
83196 JsonToken t , JsonParser p , String failureMsg ) throws IOException {
197+ Class <?> raw = targetType .getRawClass ();
198+ if (raw == String .class ) {
199+ // number / boolean -> rendered string; object / array -> null.
200+ switch (t ) {
201+ case VALUE_NUMBER_INT :
202+ case VALUE_NUMBER_FLOAT :
203+ case VALUE_TRUE :
204+ case VALUE_FALSE :
205+ return p .getText ();
206+ default :
207+ break ;
208+ }
209+ } else if (t == JsonToken .VALUE_TRUE || t == JsonToken .VALUE_FALSE ) {
210+ long b = (t == JsonToken .VALUE_TRUE ) ? 1L : 0L ;
211+ if (isIntegral (raw )) return toIntegral (b , raw );
212+ if (isFloating (raw )) return toFloating (b , raw );
213+ }
84214 skipCurrentValue (p , t );
85215 return null ;
86216 }
87217
88- /** Numeric field arriving as a non-numeric string ({@code "n/a"}). */
218+ /**
219+ * A string arriving for a non-string target. Parse whole-string
220+ * numerics for numeric targets, map the boolean whitelist for
221+ * boolean targets; otherwise degrade to {@code null}.
222+ */
89223 @ Override
90224 public Object handleWeirdStringValue (DeserializationContext ctxt , Class <?> targetType ,
91225 String valueToConvert , String failureMsg ) {
226+ String s = valueToConvert == null ? "" : valueToConvert .trim ();
227+ if (targetType == Boolean .class || targetType == boolean .class ) {
228+ return stringToBoolean (s );
229+ }
230+ if (isIntegral (targetType )) {
231+ Long l = parseLongStrict (s );
232+ if (l == null ) {
233+ Double d = parseDoubleStrict (s ); // "8.9" for an int field -> truncate.
234+ if (d == null ) return null ;
235+ l = (long ) (double ) d ;
236+ }
237+ return toIntegral (l , targetType );
238+ }
239+ if (isFloating (targetType )) {
240+ Double d = parseDoubleStrict (s );
241+ return d == null ? null : toFloating (d , targetType );
242+ }
92243 return null ;
93244 }
94245
@@ -142,4 +293,87 @@ private static void skipCurrentValue(JsonParser p, JsonToken t) throws IOExcepti
142293 p .skipChildren ();
143294 }
144295 }
296+
297+ private static boolean isIntegral (Class <?> c ) {
298+ return c == Integer .class || c == int .class
299+ || c == Long .class || c == long .class
300+ || c == Short .class || c == short .class
301+ || c == Byte .class || c == byte .class ;
302+ }
303+
304+ private static boolean isFloating (Class <?> c ) {
305+ return c == Double .class || c == double .class
306+ || c == Float .class || c == float .class ;
307+ }
308+
309+ /** Box a long into the concrete integral type the field declares. */
310+ private static Object toIntegral (long v , Class <?> c ) {
311+ if (c == Integer .class || c == int .class ) return (int ) v ;
312+ if (c == Short .class || c == short .class ) return (short ) v ;
313+ if (c == Byte .class || c == byte .class ) return (byte ) v ;
314+ return v ; // Long / long
315+ }
316+
317+ /** Box a double into the concrete floating type the field declares. */
318+ private static Object toFloating (double v , Class <?> c ) {
319+ if (c == Float .class || c == float .class ) return (float ) v ;
320+ return v ; // Double / double
321+ }
322+
323+ /**
324+ * Map a trimmed string onto a boolean via a fixed whitelist.
325+ * {@code true/1/yes/on -> true}; {@code false/0/no/off/"" -> false};
326+ * anything else -> {@code null} (never a defaulted {@code false}).
327+ */
328+ private static Boolean stringToBoolean (String trimmed ) {
329+ String s = trimmed .toLowerCase (Locale .ROOT );
330+ switch (s ) {
331+ case "true" :
332+ case "1" :
333+ case "yes" :
334+ case "on" :
335+ return Boolean .TRUE ;
336+ case "false" :
337+ case "0" :
338+ case "no" :
339+ case "off" :
340+ case "" :
341+ return Boolean .FALSE ;
342+ default :
343+ return null ;
344+ }
345+ }
346+
347+ /** Parse an entire trimmed string as a base-10 long, or {@code null}. */
348+ private static Long parseLongStrict (String trimmed ) {
349+ if (trimmed .isEmpty ()) return null ;
350+ try {
351+ return Long .valueOf (trimmed );
352+ } catch (NumberFormatException e ) {
353+ return null ;
354+ }
355+ }
356+
357+ /**
358+ * Parse an entire trimmed string as a finite double, or {@code null}.
359+ * Rejects the non-numeric forms {@link Double#parseDouble} otherwise
360+ * accepts ({@code NaN}, {@code Infinity}, hex floats, trailing {@code d}/
361+ * {@code f} suffixes).
362+ */
363+ private static Double parseDoubleStrict (String trimmed ) {
364+ if (trimmed .isEmpty ()) return null ;
365+ // Reject anything Double.parseDouble accepts but this policy does not:
366+ // NaN/Infinity, hex floats (0x...p...), and d/f/D/F type suffixes.
367+ for (int i = 0 ; i < trimmed .length (); i ++) {
368+ char ch = trimmed .charAt (i );
369+ boolean ok = (ch >= '0' && ch <= '9' ) || ch == '.' || ch == '-' || ch == '+' || ch == 'e' || ch == 'E' ;
370+ if (!ok ) return null ;
371+ }
372+ try {
373+ double d = Double .parseDouble (trimmed );
374+ return Double .isFinite (d ) ? d : null ;
375+ } catch (NumberFormatException e ) {
376+ return null ;
377+ }
378+ }
145379}
0 commit comments