Skip to content

Commit c9d7851

Browse files
committed
Harden response parsing and thread-safety; release v1.5.15
Response decoding now tolerates wrong-typed fields the same way it already tolerated missing ones: a scalar field arriving as an object/array, a numeric field (score, audio_id) arriving as a non-numeric string, or a provider block arriving as a bare string all degrade the affected field to null instead of failing the whole response. Genuinely undecodable JSON, transport errors, and status=error bodies still surface as before. Sub-client accessors (streams, customCatalog, advanced) are now synchronized so concurrent first access returns a single shared instance. Bump README install snippets and the SDK version to 1.5.15.
1 parent a9d02d2 commit c9d7851

7 files changed

Lines changed: 165 additions & 8 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.7</version>
20+
<version>1.5.15</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.7")
29+
implementation("io.audd:audd:1.5.15")
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.14</version>
9+
<version>1.5.15</version>
1010
<packaging>jar</packaging>
1111

1212
<name>AudD Java SDK</name>

src/main/java/io/audd/AudD.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ public List<EnterpriseMatch> recognizeEnterprise(Object source, EnterpriseOption
219219
return out;
220220
}
221221

222-
public Streams streams() {
222+
public synchronized Streams streams() {
223223
if (streams == null) {
224224
streams = new Streams(http, readPolicy, mutatingPolicy, apiToken::get, onDeprecation, apiBase);
225225
}
@@ -250,7 +250,7 @@ public void setApiToken(String newToken) {
250250
enterpriseHttp.setApiToken(newToken);
251251
}
252252

253-
public CustomCatalog customCatalog() {
253+
public synchronized CustomCatalog customCatalog() {
254254
if (customCatalog == null) {
255255
// Custom-catalog upload is metered. Auto-retry on a transport
256256
// failure could double-charge for the same audio fingerprinting,
@@ -266,7 +266,7 @@ public CustomCatalog customCatalog() {
266266
return customCatalog;
267267
}
268268

269-
public Advanced advanced() {
269+
public synchronized Advanced advanced() {
270270
if (advanced == null) {
271271
// C2: Advanced uses RECOGNITION policy (find_lyrics is metered).
272272
advanced = new Advanced(http, recognitionPolicy, onDeprecation, apiBase);

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
package io.audd.internal;
22

3+
import com.fasterxml.jackson.core.JsonParser;
4+
import com.fasterxml.jackson.core.JsonToken;
5+
import com.fasterxml.jackson.databind.DeserializationContext;
36
import com.fasterxml.jackson.databind.DeserializationFeature;
7+
import com.fasterxml.jackson.databind.JavaType;
48
import com.fasterxml.jackson.databind.ObjectMapper;
9+
import com.fasterxml.jackson.databind.deser.DeserializationProblemHandler;
10+
11+
import java.io.IOException;
512

613
/**
714
* Shared, leniently-configured {@link ObjectMapper} for all response parsing.
@@ -42,15 +49,97 @@ public static ObjectMapper mapper() {
4249
* </ul>
4350
*/
4451
public static ObjectMapper newLenientMapper() {
45-
return new ObjectMapper()
52+
ObjectMapper mapper = new ObjectMapper()
4653
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
4754
.configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, false)
4855
.configure(DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES, false)
56+
.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false)
4957
.configure(DeserializationFeature.FAIL_ON_NUMBERS_FOR_ENUMS, false)
5058
.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true)
5159
.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
5260
.configure(DeserializationFeature.UNWRAP_SINGLE_VALUE_ARRAYS, true)
5361
.configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true)
5462
.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+
}
55144
}
56145
}

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.14";
5+
public static final String SDK_VERSION = "1.5.15";
66

77
private UserAgent() {}
88

src/test/java/io/audd/AudDTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,14 @@ void hierarchicalSubclients_areLazilyCreated() {
194194
assertThat(audd.customCatalog()).isNotNull();
195195
assertThat(audd.advanced()).isNotNull();
196196
}
197+
198+
@Test
199+
void offsetToSeconds_parsesOffsetsOverOneHour() {
200+
// "01:02:03" -> 1h 2m 3s = 3723s. Regression: hours must be honoured.
201+
assertThat(AudD.offsetToSeconds("01:02:03")).isEqualTo(3723.0);
202+
assertThat(AudD.offsetToSeconds("10:00:00")).isEqualTo(36000.0);
203+
assertThat(AudD.offsetToSeconds("00:42")).isEqualTo(42.0);
204+
assertThat(AudD.offsetToSeconds("90")).isEqualTo(90.0);
205+
assertThat(AudD.offsetToSeconds("garbage")).isNull();
206+
}
197207
}

src/test/java/io/audd/models/EnterpriseLenientParseTest.java

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,62 @@ void enterpriseResponse_withEmptyAndAbsentArrays_doesNotThrow() {
7474
assertThat(c.songs()).isNull();
7575
}).doesNotThrowAnyException();
7676
}
77+
78+
@Test
79+
void enterpriseSong_withScoreAsNonNumericString_degradesToNull() throws Exception {
80+
// score wired as a non-numeric string must degrade to null, not throw.
81+
String json = "{\"songs\":[{\"artist\":\"x\",\"title\":\"y\",\"score\":\"n/a\"}]}";
82+
83+
EnterpriseChunkResult chunk = M.readValue(json, EnterpriseChunkResult.class);
84+
EnterpriseMatch m = chunk.songs().get(0);
85+
assertThat(m.artist()).isEqualTo("x");
86+
assertThat(m.score()).isNull();
87+
}
88+
89+
@Test
90+
void enterpriseSong_withProviderBlockAsString_degradesAndKeepsSiblings() throws Exception {
91+
// A nested provider-shaped field arriving as a bare string is skipped;
92+
// the rest of the song still decodes.
93+
String json = "{\"songs\":[{\"artist\":\"x\",\"title\":\"y\","
94+
+ "\"start_offset\":1500,\"apple_music\":\"unavailable\"}]}";
95+
96+
EnterpriseChunkResult chunk = M.readValue(json, EnterpriseChunkResult.class);
97+
EnterpriseMatch m = chunk.songs().get(0);
98+
assertThat(m.title()).isEqualTo("y");
99+
assertThat(m.startOffset()).isEqualTo(1500);
100+
}
101+
102+
@Test
103+
void recognitionResult_withAudioIdAsNonNumericString_degradesToNull() throws Exception {
104+
String json = "{\"artist\":\"a\",\"title\":\"t\",\"audio_id\":\"not-a-number\"}";
105+
106+
RecognitionResult r = M.readValue(json, RecognitionResult.class);
107+
assertThat(r.artist()).isEqualTo("a");
108+
assertThat(r.audioId()).isNull();
109+
}
110+
111+
@Test
112+
void recognitionResult_withSpotifyNameAsObject_degradesFieldNotWholeBlock() throws Exception {
113+
// spotify.name (a String field) arrives as an object: skip the offending
114+
// value, keep the rest of the spotify block and the outer result.
115+
String json = "{\"artist\":\"a\",\"title\":\"t\",\"spotify\":{"
116+
+ "\"id\":\"abc123\",\"name\":{\"unexpected\":\"object\"},\"popularity\":77}}";
117+
118+
RecognitionResult r = M.readValue(json, RecognitionResult.class);
119+
assertThat(r.title()).isEqualTo("t");
120+
assertThat(r.spotify()).isNotNull();
121+
assertThat(r.spotify().name()).isNull();
122+
assertThat(r.spotify().id).isEqualTo("abc123");
123+
assertThat(r.spotify().popularity).isEqualTo(77);
124+
}
125+
126+
@Test
127+
void recognitionResult_withSpotifyBlockAsString_degradesToNull() throws Exception {
128+
// Whole spotify object typed as a scalar arriving as a string.
129+
String json = "{\"artist\":\"a\",\"title\":\"t\",\"spotify\":\"n/a\"}";
130+
131+
RecognitionResult r = M.readValue(json, RecognitionResult.class);
132+
assertThat(r.artist()).isEqualTo("a");
133+
assertThat(r.spotify()).isNull();
134+
}
77135
}

0 commit comments

Comments
 (0)