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