Skip to content

Commit c00f387

Browse files
committed
Coerce convertible wrong-typed scalar fields in lenient parsing
Wrong-but-convertible scalar fields are now rendered into the declared type instead of being dropped; only the genuinely unconvertible degrades to null (never a garbage 0/false): - String field <- number/boolean: rendered text (85 -> "85", true -> "true"); object/array -> null. - int/long field <- whole-number string parses, float/double truncates, boolean maps to 0/1; non-numeric string, object, array -> null. - float/double field <- numeric string parses, int converts; else null. - boolean field <- number by value != 0; string via a fixed whitelist (true/1/yes/on -> true, false/0/no/off/"" -> false), any other string, object, array -> null. Numeric-string parsing is strict over the whole trimmed string, so "85abc"/"NaN"/"Infinity" do not slip through as numbers. A container arriving where a scalar is expected is skipped to null rather than crashing. Coercion lives only on the lenient failure path. Version bump to 1.5.16.
1 parent c9d7851 commit c00f387

5 files changed

Lines changed: 373 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Maven:
1717
<dependency>
1818
<groupId>io.audd</groupId>
1919
<artifactId>audd</artifactId>
20-
<version>1.5.15</version>
20+
<version>1.5.16</version>
2121
</dependency>
2222
```
2323

@@ -26,7 +26,7 @@ Get your API token at [dashboard.audd.io](https://dashboard.audd.io).
2626
Gradle (Kotlin DSL):
2727

2828
```kotlin
29-
implementation("io.audd:audd:1.5.15")
29+
implementation("io.audd:audd:1.5.16")
3030
```
3131

3232
Java 11+. Modular consumers: `requires io.audd;`.

pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<groupId>io.audd</groupId>
88
<artifactId>audd</artifactId>
9-
<version>1.5.15</version>
9+
<version>1.5.16</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AudD Java SDK</name>

src/main/java/io/audd/internal/Json.java

Lines changed: 246 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22

33
import com.fasterxml.jackson.core.JsonParser;
44
import com.fasterxml.jackson.core.JsonToken;
5+
import com.fasterxml.jackson.databind.BeanDescription;
6+
import com.fasterxml.jackson.databind.DeserializationConfig;
57
import com.fasterxml.jackson.databind.DeserializationContext;
68
import com.fasterxml.jackson.databind.DeserializationFeature;
79
import com.fasterxml.jackson.databind.JavaType;
10+
import com.fasterxml.jackson.databind.JsonDeserializer;
811
import com.fasterxml.jackson.databind.ObjectMapper;
12+
import com.fasterxml.jackson.databind.deser.BeanDeserializerModifier;
913
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler;
14+
import com.fasterxml.jackson.databind.module.SimpleModule;
1015

1116
import java.io.IOException;
17+
import java.util.Locale;
1218

1319
/**
1420
* Shared, leniently-configured {@link ObjectMapper} for all response parsing.
@@ -18,7 +24,8 @@
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
}

src/main/java/io/audd/internal/UserAgent.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
/** SDK identifier sent on every request. */
44
public final class UserAgent {
5-
public static final String SDK_VERSION = "1.5.15";
5+
public static final String SDK_VERSION = "1.5.16";
66

77
private UserAgent() {}
88

0 commit comments

Comments
 (0)