From d43a382296310b6997f794d1af73e959bca58b63 Mon Sep 17 00:00:00 2001
From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com>
Date: Thu, 30 Apr 2026 13:03:52 +0000
Subject: [PATCH 1/2] fix: use field-presence checks in union deserializers to
prevent misclassification
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously, all union response deserializers attempted blind `convertValue`
calls in sequence, catching `RuntimeException` to fall through to the next
candidate type. This approach could silently misclassify a response payload
as the wrong union variant when Jackson's lenient conversion succeeded on an
incompatible type.
The deserializers now inspect the raw `Map` keys first, requiring the
presence of discriminating fields before attempting conversion. This makes
variant selection deterministic and prevents incorrect deserialization of
ambiguous payloads.
Key changes:
- `GetAggregationCountResponse` and `PostAggregationCountResponse` deserializers now gate on `status`, `total_hits`, `page`, `total_pages`, and `page_size` keys before attempting conversion to `AggregationCountResponseDto` or `FailedAggregationCountResponseDto`
- `GetAuthorsResponse` and `PostAuthorsResponse` deserializers apply the same pagination-field guard before converting to `SearchResponseDto` or `FailedAuthorsResponseDto`
- `GetLatestHeadlinesResponse`, `PostLatestHeadlinesResponse`, `GetSearchResponse`, and `PostSearchResponse` deserializers additionally require `clusters_count` and `clusters` keys before attempting `ClusteredSearchResponseDto` conversion
- `AggregationCountResponseDtoAggregations` deserializer gates on `aggregation_count` key before attempting `AggregationItem` conversion
- `SourcesResponseDtoSourcesItem` deserializer gates on `domain_url` key before attempting `SourceInfo` conversion
🌿 Generated with Fern
---
.fern/metadata.json | 8 +-
README.md | 5 +-
build.gradle | 4 +-
changelog.md | 19 +++++
.../newscatcher/api/core/ClientOptions.java | 4 +-
.../types/GetAggregationCountResponse.java | 27 ++++--
.../types/PostAggregationCountResponse.java | 27 ++++--
.../authors/types/GetAuthorsResponse.java | 27 ++++--
.../authors/types/PostAuthorsResponse.java | 27 ++++--
.../types/GetLatestHeadlinesResponse.java | 29 +++++--
.../types/PostLatestHeadlinesResponse.java | 29 +++++--
.../search/types/GetSearchResponse.java | 29 +++++--
.../search/types/PostSearchResponse.java | 29 +++++--
...gregationCountResponseDtoAggregations.java | 9 +-
.../types/SourcesResponseDtoSourcesItem.java | 9 +-
.../api/UndiscriminatedUnionTest.java | 84 +++++++++++++++++++
16 files changed, 302 insertions(+), 64 deletions(-)
create mode 100644 src/test/java/com/newscatcher/api/UndiscriminatedUnionTest.java
diff --git a/.fern/metadata.json b/.fern/metadata.json
index 96f3c55..14f1e20 100644
--- a/.fern/metadata.json
+++ b/.fern/metadata.json
@@ -1,14 +1,14 @@
{
- "cliVersion": "4.90.0",
+ "cliVersion": "5.1.0",
"generatorName": "fernapi/fern-java-sdk",
- "generatorVersion": "4.4.1",
+ "generatorVersion": "4.6.4",
"generatorConfig": {
"publish-to": "central"
},
- "originGitCommit": "3707beee5d0e236f2469e89f7b19c3a08efb6f95",
+ "originGitCommit": "57be6b5662276253461696b49ad9ff6448cc29f3",
"originGitCommitIsDirty": true,
"invokedBy": "ci",
"requestedVersion": "AUTO",
"ciProvider": "unknown",
- "sdkVersion": "2.1.0"
+ "sdkVersion": "2.1.1"
}
\ No newline at end of file
diff --git a/README.md b/README.md
index 4b42499..75e124b 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ Add the dependency in your `build.gradle` file:
```groovy
dependencies {
- implementation 'com.newscatcherapi:newscatcher-sdk:2.1.0'
+ implementation 'com.newscatcherapi:newscatcher-sdk:2.1.1'
}
```
@@ -55,7 +55,7 @@ Add the dependency in your `pom.xml` file:
com.newscatcherapi
newscatcher-sdk
- 2.1.0
+ 2.1.1
```
@@ -354,3 +354,4 @@ On the other hand, contributions to the README are always very welcome!
For release notes and changelog, visit our [GitHub releases page](https://github.com/Newscatcher/newscatcher-java/releases).
+
diff --git a/build.gradle b/build.gradle
index dda67db..9e31bef 100644
--- a/build.gradle
+++ b/build.gradle
@@ -47,7 +47,7 @@ java {
group = 'com.newscatcherapi'
-version = '2.1.0'
+version = '2.1.1'
jar {
dependsOn(":generatePomFileForMavenPublication")
@@ -78,7 +78,7 @@ publishing {
maven(MavenPublication) {
groupId = 'com.newscatcherapi'
artifactId = 'newscatcher-sdk'
- version = '2.1.0'
+ version = '2.1.1'
from components.java
pom {
name = 'newscatcher'
diff --git a/changelog.md b/changelog.md
index 6006270..b00138f 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,3 +1,22 @@
+## 2.1.1 - 2026-04-30
+* fix: use field-presence checks in union deserializers to prevent misclassification
+* Previously, all union response deserializers attempted blind `convertValue`
+* calls in sequence, catching `RuntimeException` to fall through to the next
+* candidate type. This approach could silently misclassify a response payload
+* as the wrong union variant when Jackson's lenient conversion succeeded on an
+* incompatible type.
+* The deserializers now inspect the raw `Map` keys first, requiring the
+* presence of discriminating fields before attempting conversion. This makes
+* variant selection deterministic and prevents incorrect deserialization of
+* ambiguous payloads.
+* Key changes:
+* `GetAggregationCountResponse` and `PostAggregationCountResponse` deserializers now gate on `status`, `total_hits`, `page`, `total_pages`, and `page_size` keys before attempting conversion to `AggregationCountResponseDto` or `FailedAggregationCountResponseDto`
+* `GetAuthorsResponse` and `PostAuthorsResponse` deserializers apply the same pagination-field guard before converting to `SearchResponseDto` or `FailedAuthorsResponseDto`
+* `GetLatestHeadlinesResponse`, `PostLatestHeadlinesResponse`, `GetSearchResponse`, and `PostSearchResponse` deserializers additionally require `clusters_count` and `clusters` keys before attempting `ClusteredSearchResponseDto` conversion
+* `AggregationCountResponseDtoAggregations` deserializer gates on `aggregation_count` key before attempting `AggregationItem` conversion
+* `SourcesResponseDtoSourcesItem` deserializer gates on `domain_url` key before attempting `SourceInfo` conversion
+* 🌿 Generated with Fern
+
## 2.1.0 - 2026-04-23
* ## [2.1.0] - 2025-07-10
### Added
diff --git a/src/main/java/com/newscatcher/api/core/ClientOptions.java b/src/main/java/com/newscatcher/api/core/ClientOptions.java
index 0651c5c..608f28a 100644
--- a/src/main/java/com/newscatcher/api/core/ClientOptions.java
+++ b/src/main/java/com/newscatcher/api/core/ClientOptions.java
@@ -38,10 +38,10 @@ private ClientOptions(
this.headers.putAll(headers);
this.headers.putAll(new HashMap() {
{
- put("User-Agent", "com.newscatcherapi:newscatcher-sdk/2.1.0");
+ put("User-Agent", "com.newscatcherapi:newscatcher-sdk/2.1.1");
put("X-Fern-Language", "JAVA");
put("X-Fern-SDK-Name", "com.newscatcher.fern:api-sdk");
- put("X-Fern-SDK-Version", "2.1.0");
+ put("X-Fern-SDK-Version", "2.1.1");
}
});
this.headerSuppliers = headerSuppliers;
diff --git a/src/main/java/com/newscatcher/api/resources/aggregationcount/types/GetAggregationCountResponse.java b/src/main/java/com/newscatcher/api/resources/aggregationcount/types/GetAggregationCountResponse.java
index 69633a3..d11a778 100644
--- a/src/main/java/com/newscatcher/api/resources/aggregationcount/types/GetAggregationCountResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/aggregationcount/types/GetAggregationCountResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.AggregationCountResponseDto;
import com.newscatcher.api.types.FailedAggregationCountResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = GetAggregationCountResponse.Deserializer.class)
@@ -84,13 +85,27 @@ static final class Deserializer extends StdDeserializer
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, AggregationCountResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAggregationCountResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAggregationCountResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/aggregationcount/types/PostAggregationCountResponse.java b/src/main/java/com/newscatcher/api/resources/aggregationcount/types/PostAggregationCountResponse.java
index 6b9e621..46d162f 100644
--- a/src/main/java/com/newscatcher/api/resources/aggregationcount/types/PostAggregationCountResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/aggregationcount/types/PostAggregationCountResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.AggregationCountResponseDto;
import com.newscatcher.api.types.FailedAggregationCountResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = PostAggregationCountResponse.Deserializer.class)
@@ -84,13 +85,27 @@ static final class Deserializer extends StdDeserializer
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, AggregationCountResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAggregationCountResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAggregationCountResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/authors/types/GetAuthorsResponse.java b/src/main/java/com/newscatcher/api/resources/authors/types/GetAuthorsResponse.java
index 8589c8c..6787989 100644
--- a/src/main/java/com/newscatcher/api/resources/authors/types/GetAuthorsResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/authors/types/GetAuthorsResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.FailedAuthorsResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = GetAuthorsResponse.Deserializer.class)
@@ -83,13 +84,27 @@ static final class Deserializer extends StdDeserializer {
@java.lang.Override
public GetAuthorsResponse deserialize(JsonParser p, DeserializationContext context) throws IOException {
Object value = p.readValueAs(Object.class);
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAuthorsResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAuthorsResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/authors/types/PostAuthorsResponse.java b/src/main/java/com/newscatcher/api/resources/authors/types/PostAuthorsResponse.java
index 700a078..d281653 100644
--- a/src/main/java/com/newscatcher/api/resources/authors/types/PostAuthorsResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/authors/types/PostAuthorsResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.FailedAuthorsResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = PostAuthorsResponse.Deserializer.class)
@@ -83,13 +84,27 @@ static final class Deserializer extends StdDeserializer {
@java.lang.Override
public PostAuthorsResponse deserialize(JsonParser p, DeserializationContext context) throws IOException {
Object value = p.readValueAs(Object.class);
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAuthorsResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, FailedAuthorsResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/latestheadlines/types/GetLatestHeadlinesResponse.java b/src/main/java/com/newscatcher/api/resources/latestheadlines/types/GetLatestHeadlinesResponse.java
index 112d7ab..87f81f2 100644
--- a/src/main/java/com/newscatcher/api/resources/latestheadlines/types/GetLatestHeadlinesResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/latestheadlines/types/GetLatestHeadlinesResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.ClusteredSearchResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = GetLatestHeadlinesResponse.Deserializer.class)
@@ -83,13 +84,29 @@ static final class Deserializer extends StdDeserializer
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")
+ && ((Map, ?>) value).containsKey("clusters_count")
+ && ((Map, ?>) value).containsKey("clusters")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/latestheadlines/types/PostLatestHeadlinesResponse.java b/src/main/java/com/newscatcher/api/resources/latestheadlines/types/PostLatestHeadlinesResponse.java
index da8f572..382c252 100644
--- a/src/main/java/com/newscatcher/api/resources/latestheadlines/types/PostLatestHeadlinesResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/latestheadlines/types/PostLatestHeadlinesResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.ClusteredSearchResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = PostLatestHeadlinesResponse.Deserializer.class)
@@ -84,13 +85,29 @@ static final class Deserializer extends StdDeserializer
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")
+ && ((Map, ?>) value).containsKey("clusters_count")
+ && ((Map, ?>) value).containsKey("clusters")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/search/types/GetSearchResponse.java b/src/main/java/com/newscatcher/api/resources/search/types/GetSearchResponse.java
index ffc21e1..f4c63d8 100644
--- a/src/main/java/com/newscatcher/api/resources/search/types/GetSearchResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/search/types/GetSearchResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.ClusteredSearchResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = GetSearchResponse.Deserializer.class)
@@ -83,13 +84,29 @@ static final class Deserializer extends StdDeserializer {
@java.lang.Override
public GetSearchResponse deserialize(JsonParser p, DeserializationContext context) throws IOException {
Object value = p.readValueAs(Object.class);
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")
+ && ((Map, ?>) value).containsKey("clusters_count")
+ && ((Map, ?>) value).containsKey("clusters")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/resources/search/types/PostSearchResponse.java b/src/main/java/com/newscatcher/api/resources/search/types/PostSearchResponse.java
index baab84a..6054518 100644
--- a/src/main/java/com/newscatcher/api/resources/search/types/PostSearchResponse.java
+++ b/src/main/java/com/newscatcher/api/resources/search/types/PostSearchResponse.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.types.ClusteredSearchResponseDto;
import com.newscatcher.api.types.SearchResponseDto;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = PostSearchResponse.Deserializer.class)
@@ -83,13 +84,29 @@ static final class Deserializer extends StdDeserializer {
@java.lang.Override
public PostSearchResponse deserialize(JsonParser p, DeserializationContext context) throws IOException {
Object value = p.readValueAs(Object.class);
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
- try {
- return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
- } catch (RuntimeException e) {
+ if (value instanceof Map, ?>
+ && ((Map, ?>) value).containsKey("status")
+ && ((Map, ?>) value).containsKey("total_hits")
+ && ((Map, ?>) value).containsKey("page")
+ && ((Map, ?>) value).containsKey("total_pages")
+ && ((Map, ?>) value).containsKey("page_size")
+ && ((Map, ?>) value).containsKey("clusters_count")
+ && ((Map, ?>) value).containsKey("clusters")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, ClusteredSearchResponseDto.class));
+ } catch (RuntimeException e) {
+ }
}
throw new JsonParseException(p, "Failed to deserialize");
}
diff --git a/src/main/java/com/newscatcher/api/types/AggregationCountResponseDtoAggregations.java b/src/main/java/com/newscatcher/api/types/AggregationCountResponseDtoAggregations.java
index 7a1a0be..2070aaa 100644
--- a/src/main/java/com/newscatcher/api/types/AggregationCountResponseDtoAggregations.java
+++ b/src/main/java/com/newscatcher/api/types/AggregationCountResponseDtoAggregations.java
@@ -13,6 +13,7 @@
import com.newscatcher.api.core.ObjectMappers;
import java.io.IOException;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = AggregationCountResponseDtoAggregations.Deserializer.class)
@@ -85,9 +86,11 @@ static final class Deserializer extends StdDeserializer && ((Map, ?>) value).containsKey("aggregation_count")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, AggregationItem.class));
+ } catch (RuntimeException e) {
+ }
}
try {
return of(ObjectMappers.JSON_MAPPER.convertValue(value, new TypeReference>() {}));
diff --git a/src/main/java/com/newscatcher/api/types/SourcesResponseDtoSourcesItem.java b/src/main/java/com/newscatcher/api/types/SourcesResponseDtoSourcesItem.java
index 0466f6d..5ff9e4a 100644
--- a/src/main/java/com/newscatcher/api/types/SourcesResponseDtoSourcesItem.java
+++ b/src/main/java/com/newscatcher/api/types/SourcesResponseDtoSourcesItem.java
@@ -11,6 +11,7 @@
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.newscatcher.api.core.ObjectMappers;
import java.io.IOException;
+import java.util.Map;
import java.util.Objects;
@JsonDeserialize(using = SourcesResponseDtoSourcesItem.Deserializer.class)
@@ -82,9 +83,11 @@ static final class Deserializer extends StdDeserializer && ((Map, ?>) value).containsKey("domain_url")) {
+ try {
+ return of(ObjectMappers.JSON_MAPPER.convertValue(value, SourceInfo.class));
+ } catch (RuntimeException e) {
+ }
}
try {
return of(ObjectMappers.JSON_MAPPER.convertValue(value, String.class));
diff --git a/src/test/java/com/newscatcher/api/UndiscriminatedUnionTest.java b/src/test/java/com/newscatcher/api/UndiscriminatedUnionTest.java
new file mode 100644
index 0000000..94269dd
--- /dev/null
+++ b/src/test/java/com/newscatcher/api/UndiscriminatedUnionTest.java
@@ -0,0 +1,84 @@
+/**
+ * This file was auto-generated by Fern from our API Definition.
+ */
+package com.newscatcher.api;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import com.newscatcher.api.core.ObjectMappers;
+import com.newscatcher.api.resources.aggregationcount.types.GetAggregationCountResponse;
+import com.newscatcher.api.resources.aggregationcount.types.PostAggregationCountResponse;
+import com.newscatcher.api.resources.authors.types.GetAuthorsResponse;
+import com.newscatcher.api.resources.authors.types.PostAuthorsResponse;
+import com.newscatcher.api.resources.latestheadlines.types.GetLatestHeadlinesResponse;
+import com.newscatcher.api.resources.latestheadlines.types.PostLatestHeadlinesResponse;
+import com.newscatcher.api.resources.search.types.GetSearchResponse;
+import com.newscatcher.api.resources.search.types.PostSearchResponse;
+import com.newscatcher.api.types.AggregationCountResponseDto;
+import com.newscatcher.api.types.SearchResponseDto;
+import org.junit.jupiter.api.Test;
+
+public final class UndiscriminatedUnionTest {
+ @Test
+ public void testGetSearchResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ GetSearchResponse union = ObjectMappers.JSON_MAPPER.readValue(json, GetSearchResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testPostSearchResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ PostSearchResponse union = ObjectMappers.JSON_MAPPER.readValue(json, PostSearchResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testGetLatestHeadlinesResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ GetLatestHeadlinesResponse union = ObjectMappers.JSON_MAPPER.readValue(json, GetLatestHeadlinesResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testPostLatestHeadlinesResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ PostLatestHeadlinesResponse union =
+ ObjectMappers.JSON_MAPPER.readValue(json, PostLatestHeadlinesResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testGetAuthorsResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ GetAuthorsResponse union = ObjectMappers.JSON_MAPPER.readValue(json, GetAuthorsResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testPostAuthorsResponse_SearchResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ PostAuthorsResponse union = ObjectMappers.JSON_MAPPER.readValue(json, PostAuthorsResponse.class);
+ assertTrue(union.get() instanceof SearchResponseDto, "Expected SearchResponseDto but got different variant");
+ }
+
+ @Test
+ public void testGetAggregationCountResponse_AggregationCountResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ GetAggregationCountResponse union =
+ ObjectMappers.JSON_MAPPER.readValue(json, GetAggregationCountResponse.class);
+ assertTrue(
+ union.get() instanceof AggregationCountResponseDto,
+ "Expected AggregationCountResponseDto but got different variant");
+ }
+
+ @Test
+ public void testPostAggregationCountResponse_AggregationCountResponseDto() throws Exception {
+ String json = "{\"status\":\"test\",\"total_hits\":1,\"page\":1,\"total_pages\":1,\"page_size\":1}";
+ PostAggregationCountResponse union =
+ ObjectMappers.JSON_MAPPER.readValue(json, PostAggregationCountResponse.class);
+ assertTrue(
+ union.get() instanceof AggregationCountResponseDto,
+ "Expected AggregationCountResponseDto but got different variant");
+ }
+}
From dd0145ea9d0b59e0152cfb7cb13c0ad90d0ecebd Mon Sep 17 00:00:00 2001
From: oscatcher
Date: Fri, 1 May 2026 17:14:44 +0200
Subject: [PATCH 2/2] feat(ci): add complete sdk-release workflow
---
.fernignore | 3 +
.github/workflows/sdk-release.yml | 123 ++++++++++++++++++++++++++++++
2 files changed, 126 insertions(+)
create mode 100644 .github/workflows/sdk-release.yml
diff --git a/.fernignore b/.fernignore
index 3a2ac6b..fe52d68 100644
--- a/.fernignore
+++ b/.fernignore
@@ -2,3 +2,6 @@ LICENSE
# README.md
changelog.md
+
+# CI
+.github/workflows/sdk-release.yml
\ No newline at end of file
diff --git a/.github/workflows/sdk-release.yml b/.github/workflows/sdk-release.yml
new file mode 100644
index 0000000..be6bfee
--- /dev/null
+++ b/.github/workflows/sdk-release.yml
@@ -0,0 +1,123 @@
+name: SDK Release
+
+on:
+ workflow_dispatch:
+ pull_request:
+ types: [closed]
+ branches:
+ - main
+
+jobs:
+ compile:
+ if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ id: setup-jre
+ uses: actions/setup-java@v1
+ with:
+ java-version: "11"
+ architecture: x64
+
+ - name: Compile
+ run: ./gradlew compileJava
+
+ test:
+ needs: [compile]
+ if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repo
+ uses: actions/checkout@v4
+
+ - name: Set up Java
+ id: setup-jre
+ uses: actions/setup-java@v1
+ with:
+ java-version: "11"
+ architecture: x64
+
+ - name: Test
+ run: ./gradlew test
+
+ release:
+ needs: [compile, test]
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout main
+ uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+
+ - name: Read version from build.gradle
+ id: version
+ run: |
+ VERSION=$(grep -m1 "^version = '" build.gradle | grep -oP "'\K[^']+")
+ if [ -z "$VERSION" ]; then
+ echo "ERROR: Could not read version from build.gradle"
+ exit 1
+ fi
+ echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
+ echo "TAG=v$VERSION" >> $GITHUB_OUTPUT
+
+ - name: Get latest git tag
+ id: latest_tag
+ run: |
+ LATEST=$(git describe --tags --abbrev=0 2>/dev/null || echo "none")
+ echo "LATEST_TAG=$LATEST" >> $GITHUB_OUTPUT
+
+ - name: Check if version was bumped
+ id: check
+ run: |
+ TAG="${{ steps.version.outputs.TAG }}"
+ LATEST="${{ steps.latest_tag.outputs.LATEST_TAG }}"
+ if [ "$TAG" = "$LATEST" ]; then
+ echo "bumped=false" >> $GITHUB_OUTPUT
+ echo "Version $TAG already tagged — skipping release."
+ else
+ echo "bumped=true" >> $GITHUB_OUTPUT
+ echo "New version detected: $TAG (previous: $LATEST)"
+ fi
+
+ - name: Set up Java
+ if: steps.check.outputs.bumped == 'true'
+ uses: actions/setup-java@v1
+ with:
+ java-version: "11"
+ architecture: x64
+
+ - name: Create and push tag
+ if: steps.check.outputs.bumped == 'true'
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git tag "${{ steps.version.outputs.TAG }}"
+ git push origin "${{ steps.version.outputs.TAG }}"
+
+ - name: Publish to Maven
+ if: steps.check.outputs.bumped == 'true'
+ run: ./gradlew sonatypeCentralUpload
+ env:
+ MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
+ MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+ MAVEN_PUBLISH_REGISTRY_URL: "https://oss.sonatype.org/service/local/staging/deploy/maven2/"
+ MAVEN_SIGNATURE_KID: ${{ secrets.MAVEN_SIGNATURE_KID }}
+ MAVEN_SIGNATURE_SECRET_KEY: ${{ secrets.MAVEN_SIGNATURE_SECRET_KEY }}
+ MAVEN_SIGNATURE_PASSWORD: ${{ secrets.MAVEN_SIGNATURE_PASSWORD }}
+
+ - name: Create GitHub release with generated notes
+ if: steps.check.outputs.bumped == 'true'
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ steps.version.outputs.TAG }}" \
+ --title "${{ steps.version.outputs.TAG }}" \
+ --generate-notes \
+ --latest
\ No newline at end of file