From 634dfbf56bc13768f805ca02b5d8ad7c0c0953ec Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 05:13:23 +0000 Subject: [PATCH 01/45] build(deps): Bump com.gradle:common-custom-user-data-maven-extension Bumps [com.gradle:common-custom-user-data-maven-extension](https://github.com/gradle/common-custom-user-data-maven-extension) from 2.3.0 to 2.4.0. - [Release notes](https://github.com/gradle/common-custom-user-data-maven-extension/releases) - [Commits](https://github.com/gradle/common-custom-user-data-maven-extension/compare/v2.3.0...v2.4.0) --- updated-dependencies: - dependency-name: com.gradle:common-custom-user-data-maven-extension dependency-version: 2.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .mvn/extensions.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 2ce7b7f8a..6945fb710 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -24,6 +24,6 @@ com.gradle common-custom-user-data-maven-extension - 2.3.0 + 2.4.0 From 088c5e0e700a8874172e922500ed2080c8218ae8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:12:36 +0000 Subject: [PATCH 02/45] build(deps): Bump com.alibaba.fastjson2:fastjson2 Bumps [com.alibaba.fastjson2:fastjson2](https://github.com/alibaba/fastjson2) from 2.0.63.android8 to 2.0.64.android8. - [Release notes](https://github.com/alibaba/fastjson2/releases) - [Commits](https://github.com/alibaba/fastjson2/compare/2.0.63.android8...2.0.64.android8) --- updated-dependencies: - dependency-name: com.alibaba.fastjson2:fastjson2 dependency-version: 2.0.64.android8 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 47e6308c3..62ff5e96f 100644 --- a/pom.xml +++ b/pom.xml @@ -178,7 +178,7 @@ 3.2.1 3.27.7 5.23.0 - 2.0.63.android8 + 2.0.64.android8 1.5.3 6.0 From f8f1f9286325bd7d7cc7e2b075624eaf9d66f5a8 Mon Sep 17 00:00:00 2001 From: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:48:09 +0900 Subject: [PATCH 03/45] Avoid default Content-Type on POST/PUT/PATCH with empty body DefaultClient wrote a zero-length byte array to the connection's output stream whenever the HTTP method was not GET, even when the request body was empty. Opening that output stream makes HttpURLConnection create an internal "poster" and, as soon as one exists, the JDK defaults Content-Type to application/x-www-form-urlencoded if none was set - even though no bytes are actually written. This regressed in 12.0 (feign#1778, fixing the Content-Length: 0 case) and silently adds an incorrect Content-Type header, which can cause servers to reject the request with 415 Unsupported Media Type. Skip the output stream entirely for empty/absent bodies and set Content-Length: 0 directly, matching the existing null-body path. Fixes #2068 Signed-off-by: seonwoo_jung <79202163+seonwooj0810@users.noreply.github.com> --- core/src/main/java/feign/DefaultClient.java | 19 ++++++++++--------- .../java/feign/client/AbstractClientTest.java | 13 +++++++++++++ .../java/feign/client/DefaultClientTest.java | 9 +++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/core/src/main/java/feign/DefaultClient.java b/core/src/main/java/feign/DefaultClient.java index 6181a1033..54af6e6cd 100644 --- a/core/src/main/java/feign/DefaultClient.java +++ b/core/src/main/java/feign/DefaultClient.java @@ -193,11 +193,7 @@ else if (field.equals(ACCEPT_ENCODING)) { byte[] body = request.body(); - if (body != null && (body.length > 0 || request.httpMethod() != Request.HttpMethod.GET)) { - /* - * Ignore disableRequestBuffering flag if the empty body was set, to ensure that internal - * retry logic applies to such requests. - */ + if (body != null && body.length > 0) { if (disableRequestBuffering) { if (contentLength != null) { connection.setFixedLengthStreamingMode(contentLength); @@ -220,10 +216,15 @@ else if (field.equals(ACCEPT_ENCODING)) { } catch (IOException suppressed) { // NOPMD } } - } - - if (body == null && request.httpMethod().isWithBody()) { - // To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true. + } else if (request.httpMethod().isWithBody()) { + /* + * Avoid calling connection.getOutputStream() for an empty body: HttpURLConnection defaults + * the Content-Type to application/x-www-form-urlencoded as soon as an output stream (a + * "poster") is created, even when zero bytes are written to it. Setting Content-Length + * directly ensures internal retry logic still applies to such requests, without triggering + * that default. + * To use this Header, set 'sun.net.http.allowRestrictedHeaders' property true. + */ connection.addRequestProperty("Content-Length", "0"); } diff --git a/core/src/test/java/feign/client/AbstractClientTest.java b/core/src/test/java/feign/client/AbstractClientTest.java index b37fd7d7f..8cb1ea0ae 100644 --- a/core/src/test/java/feign/client/AbstractClientTest.java +++ b/core/src/test/java/feign/client/AbstractClientTest.java @@ -229,6 +229,16 @@ public void noResponseBodyForPut() throws Exception { api.noPutBody(); } + @Test + void emptyStringBodyForPost() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = + newBuilder().target(TestInterface.class, "http://localhost:" + server.getPort()); + + api.postEmptyStringBody(""); + } + /** * Some client implementation tests should override this test if the PATCH operation is * unsupported. @@ -612,6 +622,9 @@ public interface TestInterface { @RequestLine("POST") String noPostBody(); + @RequestLine("POST /") + String postEmptyStringBody(String body); + @RequestLine("PUT") String noPutBody(); diff --git a/core/src/test/java/feign/client/DefaultClientTest.java b/core/src/test/java/feign/client/DefaultClientTest.java index 35d314fee..07c4cec55 100644 --- a/core/src/test/java/feign/client/DefaultClientTest.java +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -165,6 +165,15 @@ void emptyBodyDoesNotConvertGetToPost() throws Exception { MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET"); } + @Test + @Override + void emptyStringBodyForPost() throws Exception { + super.emptyStringBodyForPost(); + MockWebServerAssertions.assertThat(server.takeRequest()) + .hasMethod("POST") + .hasNoHeaderNamed("Content-Type"); + } + @Test @Override public void noResponseBodyForPut() throws Exception { From 44f688ef3ebbd57e2ceaa9404e8e4f9901148ebe Mon Sep 17 00:00:00 2001 From: kalayciburak Date: Sun, 9 Aug 2026 13:41:19 +0300 Subject: [PATCH 04/45] Flatten feign-bom so imports do not override consumer dependency management Installing and deploying feign-bom previously published a POM that still parented feign-parent. Importing that BOM into a project such as Spring Boot also imported parent dependencyManagement (for example jackson-bom) and overrode the consumer's managed versions. Apply flatten-maven-plugin in bom mode on the BOM module and template so the published artifact only manages Feign modules. Fixes #3505 --- .gitignore | 3 +++ CHANGELOG.md | 2 ++ feign-bom/pom.xml | 34 ++++++++++++++++++++++++++++++++++ src/config/bom.xml | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 73 insertions(+) diff --git a/.gitignore b/.gitignore index 53adc1616..4e6917fa1 100644 --- a/.gitignore +++ b/.gitignore @@ -76,3 +76,6 @@ release.properties pom.xml.releaseBackup .mvn/.develocity/develocity-workspace-id .sdkmanrc + +# flatten-maven-plugin +.flattened-pom.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 51fad7326..811b0eeca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and incorporates a body hash into the cache key to reduce cross-body collisions. +* Flatten `feign-bom` on install/deploy so importing the BOM does not pull `feign-parent` + dependency management (for example Jackson) into consumer projects such as Spring Boot. ### Version 13.12 diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index 6ba3f8161..56db0a4a6 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -262,4 +262,38 @@ + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + bom + + remove + + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + diff --git a/src/config/bom.xml b/src/config/bom.xml index a7f2fdab3..1d903eaef 100644 --- a/src/config/bom.xml +++ b/src/config/bom.xml @@ -65,4 +65,38 @@ + + + + + org.codehaus.mojo + flatten-maven-plugin + 1.7.3 + + bom + + remove + + + + + flatten + process-resources + + flatten + + + + flatten.clean + clean + + clean + + + + + + + From 17f97326903df24c4c18e38b60e7ba01e7ab0224 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:20:29 +0000 Subject: [PATCH 05/45] build(deps): Bump org.apache.httpcomponents.client5:httpclient5 Bumps [org.apache.httpcomponents.client5:httpclient5](https://github.com/apache/httpcomponents-client) from 5.6.3 to 5.6.4. - [Changelog](https://github.com/apache/httpcomponents-client/blob/rel/v5.6.4/RELEASE_NOTES.txt) - [Commits](https://github.com/apache/httpcomponents-client/compare/rel/v5.6.3...rel/v5.6.4) --- updated-dependencies: - dependency-name: org.apache.httpcomponents.client5:httpclient5 dependency-version: 5.6.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 62ff5e96f..5b9a274c0 100644 --- a/pom.xml +++ b/pom.xml @@ -222,7 +222,7 @@ 26.0 4.5.4 4.5.14 - 5.6.3 + 5.6.4 1.13.0 1.5.18 3.1.0 From 675eddc2ff736188fbca86c728d76d4171cf109e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:20:49 +0000 Subject: [PATCH 06/45] build(deps): Bump netty.version from 4.2.16.Final to 4.2.17.Final Bumps `netty.version` from 4.2.16.Final to 4.2.17.Final. Updates `io.netty:netty-bom` from 4.2.16.Final to 4.2.17.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.16.Final...netty-4.2.17.Final) Updates `io.netty:netty-handler` from 4.2.16.Final to 4.2.17.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.16.Final...netty-4.2.17.Final) Updates `io.netty:netty-codec-http` from 4.2.16.Final to 4.2.17.Final - [Release notes](https://github.com/netty/netty/releases) - [Commits](https://github.com/netty/netty/compare/netty-4.2.16.Final...netty-4.2.17.Final) --- updated-dependencies: - dependency-name: io.netty:netty-bom dependency-version: 4.2.17.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-handler dependency-version: 4.2.17.Final dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: io.netty:netty-codec-http dependency-version: 4.2.17.Final dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- benchmark/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/pom.xml b/benchmark/pom.xml index 74bbbb22e..309526796 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -32,7 +32,7 @@ 1.37 0.5.3 1.3.8 - 4.2.16.Final + 4.2.17.Final true From 52719688d9d0abca4d0cda652f140d98acf78cb8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:21:27 +0000 Subject: [PATCH 07/45] build(deps-dev): Bump vertx.version in /vertx/feign-vertx5-test Bumps `vertx.version` from 5.1.5 to 5.1.6. Updates `io.vertx:vertx-junit5` from 5.1.5 to 5.1.6 - [Commits](https://github.com/eclipse-vertx/vertx-junit5/compare/5.1.5...5.1.6) Updates `io.vertx:vertx-web-client` from 5.1.5 to 5.1.6 - [Commits](https://github.com/vert-x3/vertx-web/compare/5.1.5...5.1.6) --- updated-dependencies: - dependency-name: io.vertx:vertx-junit5 dependency-version: 5.1.6 dependency-type: direct:development update-type: version-update:semver-patch - dependency-name: io.vertx:vertx-web-client dependency-version: 5.1.6 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- vertx/feign-vertx5-test/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index e97876b8c..6f3820038 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -30,7 +30,7 @@ Tests with Vertx 5.x. - 5.1.5 + 5.1.6 From 0852535dd22c33128ee6e510eca998e3fbff3d3e Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 10 Aug 2026 18:21:23 -0300 Subject: [PATCH 08/45] Add GraphQL subscription support over graphql-transport-ws Signed-off-by: Marvin Froeder --- .../graphql/apt/GraphqlSchemaProcessor.java | 13 +- graphql/README.md | 72 +++ .../java/feign/graphql/GraphqlCapability.java | 30 +- .../java/feign/graphql/GraphqlContract.java | 13 +- .../java/feign/graphql/GraphqlDecoder.java | 140 +++++- .../graphql/GraphqlSubscriptionClient.java | 458 ++++++++++++++++++ .../graphql/GraphqlSubscriptionTest.java | 420 ++++++++++++++++ 7 files changed, 1131 insertions(+), 15 deletions(-) create mode 100644 graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java create mode 100644 graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java index 2ea251ca8..22ed4e4cd 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java @@ -358,12 +358,15 @@ private boolean isJavaBuiltIn(String typeName) { return JAVA_BUILT_INS.contains(typeName); } + /** Wrappers that carry the operation result rather than being it. */ + private static final Set RESULT_CONTAINERS = Set.of("List", "Stream", "Publisher"); + private String getSimpleTypeName(TypeMirror typeMirror) { if (typeMirror instanceof DeclaredType declaredType) { var typeElement = declaredType.asElement(); var simpleName = typeElement.getSimpleName().toString(); - if ("List".equals(simpleName)) { + if (RESULT_CONTAINERS.contains(simpleName)) { var typeArgs = declaredType.getTypeArguments(); if (!typeArgs.isEmpty()) { return getSimpleTypeName(typeArgs.get(0)); @@ -376,7 +379,7 @@ private String getSimpleTypeName(TypeMirror typeMirror) { } private boolean isExistingExternalType(TypeMirror typeMirror, String targetPackage) { - var unwrapped = unwrapListTypeMirror(typeMirror); + var unwrapped = unwrapContainerTypeMirror(typeMirror); if (unwrapped.getKind() == TypeKind.ERROR) { return false; } @@ -392,13 +395,13 @@ private boolean isExistingExternalType(TypeMirror typeMirror, String targetPacka return false; } - private TypeMirror unwrapListTypeMirror(TypeMirror typeMirror) { + private TypeMirror unwrapContainerTypeMirror(TypeMirror typeMirror) { if (typeMirror instanceof DeclaredType declaredType) { var simpleName = declaredType.asElement().getSimpleName().toString(); - if ("List".equals(simpleName)) { + if (RESULT_CONTAINERS.contains(simpleName)) { var typeArgs = declaredType.getTypeArguments(); if (!typeArgs.isEmpty()) { - return typeArgs.get(0); + return unwrapContainerTypeMirror(typeArgs.get(0)); } } } diff --git a/graphql/README.md b/graphql/README.md index 6f9ba883e..dc4c05634 100644 --- a/graphql/README.md +++ b/graphql/README.md @@ -100,6 +100,78 @@ The processor generates a record for the input type as well: public record CreateUserInput(String name, String email) {} ``` +## Subscriptions + +`subscription` operations are detected from the query text and executed over the +[graphql-transport-ws](https://github.com/enisdenjo/graphql-ws/blob/master/PROTOCOL.md) WebSocket +protocol instead of HTTP. The endpoint is the target URL with its scheme swapped to `ws`/`wss`, and +one connection is opened per call. + +Queries, mutations and subscriptions can live on the same interface: only subscriptions are routed +to a WebSocket, everything else goes over the regular Feign client — including whichever one you +configured with `.client(...)` — with its own timeouts, retryer and interceptors unchanged. + +The return type decides how many events you get and whether the call blocks: + +| Return type | Events | Behaviour | +| --- | --- | --- | +| `T` | first only | blocks until the first event, then unsubscribes | +| `Optional` | first only | as above, empty if the server completes without one | +| `CompletableFuture` | first only | returns immediately, completes with the first event | +| `Stream` | all | returns once subscribed, then blocks on each element | +| `Flow.Publisher` | all | returns immediately, elements are pushed to the subscriber | + +```java +@GraphqlSchema("my-schema.graphql") +interface StockApi { + + @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }") + Price nextPrice(@Param("symbol") String symbol); + + @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }") + Stream onPrice(@Param("symbol") String symbol); + + @GraphqlQuery("subscription($symbol: String!) { priceChanged(symbol: $symbol) { symbol price } }") + Flow.Publisher publishPrice(@Param("symbol") String symbol); +} +``` + +The single-event forms close the subscription as soon as they have their event. The multi-event +forms hand you the lifecycle: closing the `Stream` — or cancelling the `Flow.Subscription` — sends +`complete` and closes the WebSocket, so consume a `Stream` with try-with-resources: + +```java +try (var prices = api.onPrice("ACME")) { + prices.forEach(System.out::println); +} +``` + +`Stream` here is the ordinary `java.util.stream.Stream`: synchronous and pull-based, with no timeout +facilities of its own. So the blocking forms — `T`, `Optional` and `Stream` — are bounded by +an event timeout, which defaults to **60 seconds** and applies to each event rather than to the +subscription as a whole. Override it when creating the capability: + +```java +Feign.builder() + // wait at most 5s for each event; Duration.ZERO waits indefinitely + .addCapability(new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5))) + .target(StockApi.class, "https://example.com/graphql"); +``` + +Exceeding it raises `SocketTimeoutException` from the blocking call or the stream element. A +subscription that can legitimately sit idle for longer needs `Duration.ZERO`. + +`Flow.Publisher` and `CompletableFuture` are deliberately *not* bounded by it — their caller +already owns the deadline, via cancelling the subscription or +`get(timeout, unit)`/`orTimeout(...)`. + +`Flow.Publisher` is `java.util.concurrent.Flow.Publisher`, so it plugs into Reactor +(`JdkFlowAdapter.flowPublisherToFlux`) or RxJava (`Flowable.fromPublisher`) without extra +dependencies here. + +A server `error` message, or `errors` inside a payload, is raised as `GraphqlErrorException`. +Request headers (for example `Authorization`) are forwarded to the WebSocket handshake. + ## Custom Scalars When your schema defines custom scalars, map them to Java types using `@Scalar` on default methods: diff --git a/graphql/src/main/java/feign/graphql/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java index 9ace756bd..1e24bc778 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlCapability.java +++ b/graphql/src/main/java/feign/graphql/GraphqlCapability.java @@ -16,6 +16,7 @@ package feign.graphql; import feign.Capability; +import feign.Client; import feign.Contract; import feign.Experimental; import feign.RequestInterceptors; @@ -24,6 +25,7 @@ import feign.codec.JsonCodec; import feign.codec.JsonDecoder; import feign.codec.JsonEncoder; +import java.time.Duration; import java.util.ArrayList; @Experimental @@ -33,15 +35,36 @@ public class GraphqlCapability implements Capability { private final GraphqlEncoder graphqlEncoder; private final GraphqlDecoder graphqlDecoder; private final GraphqlRequestInterceptor interceptor; + private final JsonEncoder jsonEncoder; + private final JsonDecoder jsonDecoder; public GraphqlCapability(JsonCodec codec) { this(codec.encoder(), codec.decoder()); } + /** + * @param eventTimeout how long a blocking subscription call waits for an event before failing + * with {@link java.net.SocketTimeoutException}; {@link java.time.Duration#ZERO} waits + * indefinitely. Does not apply to {@code Flow.Publisher} or {@code CompletableFuture} + * subscriptions, whose caller owns the deadline. + */ + public GraphqlCapability(JsonCodec codec, Duration eventTimeout) { + this(codec.encoder(), codec.decoder(), eventTimeout); + } + public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) { + this(encoder, decoder, GraphqlDecoder.DEFAULT_EVENT_TIMEOUT); + } + + /** + * @param eventTimeout see {@link #GraphqlCapability(JsonCodec, Duration)} + */ + public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout) { this.graphqlEncoder = new GraphqlEncoder(encoder, contract); - this.graphqlDecoder = new GraphqlDecoder(decoder); + this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout); this.interceptor = new GraphqlRequestInterceptor(encoder, contract); + this.jsonEncoder = encoder; + this.jsonDecoder = decoder; } @Override @@ -59,6 +82,11 @@ public Decoder enrich(Decoder decoder) { return graphqlDecoder; } + @Override + public Client enrich(Client client) { + return new GraphqlSubscriptionClient(client, contract, jsonEncoder, jsonDecoder); + } + @Override public RequestInterceptors enrich(RequestInterceptors requestInterceptors) { var enriched = new ArrayList<>(requestInterceptors.interceptors()); diff --git a/graphql/src/main/java/feign/graphql/GraphqlContract.java b/graphql/src/main/java/feign/graphql/GraphqlContract.java index 7e882fcfa..a6bf8ae78 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlContract.java +++ b/graphql/src/main/java/feign/graphql/GraphqlContract.java @@ -31,6 +31,8 @@ public class GraphqlContract extends DefaultContract { private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\$\\s*(\\w+)\\s*:"); + private static final Pattern SUBSCRIPTION_PATTERN = Pattern.compile("^\\s*subscription\\b"); + private final Map metadata = new ConcurrentHashMap<>(); public GraphqlContract() { @@ -45,7 +47,8 @@ public GraphqlContract() { } var variableName = extractFirstVariable(query); - metadata.put(data.configKey(), new QueryMetadata(query, variableName)); + metadata.put( + data.configKey(), new QueryMetadata(query, variableName, isSubscription(query))); }); } @@ -97,13 +100,19 @@ static String extractFirstVariable(String query) { return null; } + static boolean isSubscription(String query) { + return SUBSCRIPTION_PATTERN.matcher(query).find(); + } + static class QueryMetadata { final String query; final String variableName; + final boolean subscription; - QueryMetadata(String query, String variableName) { + QueryMetadata(String query, String variableName, boolean subscription) { this.query = query; this.variableName = variableName; + this.subscription = subscription; } } } diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java index 4486a239f..daada7143 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -16,29 +16,53 @@ package feign.graphql; import feign.Experimental; +import feign.Request; import feign.Response; import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.graphql.GraphqlSubscriptionClient.Subscription; import java.io.IOException; +import java.io.UncheckedIOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow; +import java.util.concurrent.SubmissionPublisher; +import java.util.stream.Stream; @Experimental public class GraphqlDecoder implements Decoder { + /** How long a blocking subscription call waits for an event before giving up. */ + public static final Duration DEFAULT_EVENT_TIMEOUT = Duration.ofSeconds(60); + private final JsonDecoder jsonDecoder; + private final long eventTimeoutMillis; public GraphqlDecoder(JsonDecoder jsonDecoder) { + this(jsonDecoder, DEFAULT_EVENT_TIMEOUT); + } + + public GraphqlDecoder(JsonDecoder jsonDecoder, Duration eventTimeout) { + if (eventTimeout.isNegative()) { + throw new IllegalArgumentException("eventTimeout must not be negative: " + eventTimeout); + } this.jsonDecoder = jsonDecoder; + this.eventTimeoutMillis = eventTimeout.toMillis(); } @Override public Object decode(Response response, Type type) throws IOException { + if (response.body() instanceof Subscription subscription) { + return subscribe(subscription, type); + } + Type targetType = type; boolean optional = isOptionalType(type); if (optional) { @@ -66,11 +90,16 @@ private Object doDecode(Response response, Type type) throws IOException { return Util.emptyValueOf(type); } + return unwrap(root, type, response.status(), response.request()); + } + + @SuppressWarnings("unchecked") + private Object unwrap(Map root, Type type, int status, Request request) + throws IOException { var errors = root.get("errors"); if (errors instanceof List errorList && !errorList.isEmpty()) { - var operationField = resolveOperationField(root, response); - throw new GraphqlErrorException( - response.status(), operationField, errors.toString(), response.request()); + var operationField = resolveOperationField(root, request); + throw new GraphqlErrorException(status, operationField, errors.toString(), request); } var data = root.get("data"); @@ -101,7 +130,7 @@ private Object doDecode(Response response, Type type) throws IOException { } @SuppressWarnings("unchecked") - private String resolveOperationField(Map root, Response response) { + private String resolveOperationField(Map root, Request request) { var data = root.get("data"); if (data instanceof Map) { var dataMap = (Map) data; @@ -111,14 +140,14 @@ private String resolveOperationField(Map root, Response response } } - if (response.request() != null && response.request().body() != null) { + if (request != null && request.body() != null) { try { var fakeResponse = Response.builder() .status(200) .headers(Collections.emptyMap()) - .request(response.request()) - .body(response.request().body()) + .request(request) + .body(request.body()) .build(); var requestBody = (Map) jsonDecoder.decode(fakeResponse, Map.class); if (requestBody != null) { @@ -135,6 +164,103 @@ private String resolveOperationField(Map root, Response response return "unknown"; } + /** + * The return type picks the semantics: {@code Stream} blocks on every element and {@code + * Flow.Publisher} pushes them, while {@code T} and {@code Optional} block for the first + * event only and {@code CompletableFuture} delivers that first event asynchronously. Every + * single-value form unsubscribes as soon as it has its event. + * + *

The blocking forms are bounded by the configured event timeout; the asynchronous ones are + * not, since their caller already owns the deadline. + */ + private Object subscribe(Subscription subscription, Type type) { + subscription.detach(); + + if (isRawType(type, Stream.class)) { + return elements(subscription, typeArgument(type), eventTimeoutMillis); + } + if (isRawType(type, Flow.Publisher.class)) { + return publish(subscription, typeArgument(type)); + } + if (isRawType(type, CompletableFuture.class)) { + return futureOf(subscription, typeArgument(type)); + } + if (isRawType(type, Optional.class)) { + return first(subscription, typeArgument(type), eventTimeoutMillis); + } + return first(subscription, type, eventTimeoutMillis).orElseGet(() -> Util.emptyValueOf(type)); + } + + private Optional first(Subscription subscription, Type elementType, long timeoutMillis) { + try (var elements = elements(subscription, elementType, timeoutMillis)) { + return elements.findFirst(); + } + } + + private CompletableFuture futureOf(Subscription subscription, Type elementType) { + var future = new CompletableFuture<>(); + pump( + () -> { + try { + future.complete(first(subscription, elementType, 0).orElse(null)); + } catch (Throwable e) { + future.completeExceptionally(e); + } + }); + return future; + } + + private Stream elements(Subscription subscription, Type elementType, long timeoutMillis) { + return subscription + .payloads(timeoutMillis) + .map( + payload -> { + try { + return unwrap(payload, elementType, 200, subscription.request()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + + private Flow.Publisher publish(Subscription subscription, Type elementType) { + var publisher = new SubmissionPublisher(); + pump( + () -> { + try (var elements = elements(subscription, elementType, 0)) { + var iterator = elements.iterator(); + var subscribed = false; + while (iterator.hasNext()) { + publisher.submit(iterator.next()); + // hasSubscribers() is also false before the first subscribe arrives, hence the + // latch — buffered elements hold until then. + if (subscribed && !publisher.hasSubscribers()) { + break; + } + subscribed |= publisher.hasSubscribers(); + } + publisher.close(); + } catch (Throwable e) { + publisher.closeExceptionally(e); + } + }); + return publisher; + } + + private static void pump(Runnable task) { + var thread = new Thread(task, "feign-graphql-subscription"); + thread.setDaemon(true); + thread.start(); + } + + private static boolean isRawType(Type type, Class raw) { + return type instanceof ParameterizedType pt && pt.getRawType() == raw; + } + + private static Type typeArgument(Type type) { + return ((ParameterizedType) type).getActualTypeArguments()[0]; + } + private boolean isOptionalType(Type type) { if (type instanceof ParameterizedType pt && pt.getRawType() instanceof Class cls) { return cls == Optional.class; diff --git a/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java new file mode 100644 index 000000000..df7426c51 --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java @@ -0,0 +1,458 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import feign.Client; +import feign.Experimental; +import feign.Request; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import feign.codec.JsonDecoder; +import feign.codec.JsonEncoder; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.Collections; +import java.util.Iterator; +import java.util.Locale; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Spliterator; +import java.util.Spliterators; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * Executes {@code subscription} operations over the graphql-transport-ws + * WebSocket protocol, delegating every other request to the wrapped {@link Client}. + * + *

The endpoint is the target URL with its scheme swapped to {@code ws}/{@code wss}. One + * WebSocket connection is opened per subscription call and is closed when the returned {@code + * Stream} or {@code Flow.Publisher} is closed/cancelled. + */ +@Experimental +public class GraphqlSubscriptionClient implements Client { + + private final Client delegate; + private final GraphqlContract contract; + private final JsonEncoder jsonEncoder; + private final JsonDecoder jsonDecoder; + private final HttpClient httpClient; + + public GraphqlSubscriptionClient( + Client delegate, GraphqlContract contract, JsonEncoder encoder, JsonDecoder decoder) { + this(delegate, contract, encoder, decoder, HttpClient.newHttpClient()); + } + + public GraphqlSubscriptionClient( + Client delegate, + GraphqlContract contract, + JsonEncoder encoder, + JsonDecoder decoder, + HttpClient httpClient) { + this.delegate = delegate; + this.contract = contract; + this.jsonEncoder = encoder; + this.jsonDecoder = decoder; + this.httpClient = httpClient; + } + + @Override + public Response execute(Request request, Request.Options options) throws IOException { + var meta = + request.requestTemplate() == null + ? null + : contract.lookupMetadata(request.requestTemplate()); + if (meta == null || !meta.subscription) { + return delegate.execute(request, options); + } + return subscribe(request, options, meta); + } + + private Response subscribe( + Request request, Request.Options options, GraphqlContract.QueryMetadata meta) + throws IOException { + var subscription = new Subscription(request, meta, jsonEncoder, jsonDecoder); + + var builder = httpClient.newWebSocketBuilder().subprotocols("graphql-transport-ws"); + if (options != null && options.connectTimeoutMillis() > 0) { + builder.connectTimeout(Duration.ofMillis(options.connectTimeoutMillis())); + } + request + .headers() + .forEach( + (name, values) -> { + if (isForwardable(name)) { + values.forEach(value -> builder.header(name, value)); + } + }); + + try { + subscription.attach(builder.buildAsync(webSocketUri(request.url()), subscription).join()); + } catch (CompletionException e) { + var cause = e.getCause() == null ? e : e.getCause(); + throw new IOException("failed to open GraphQL subscription to " + request.url(), cause); + } + + // 204 keeps feign's logger from draining and replacing the body, which would drop the live + // subscription. Nothing here ever crosses the wire. + return Response.builder() + .status(HttpURLConnection.HTTP_NO_CONTENT) + .reason("Subscribed") + .request(request) + .headers(Collections.emptyMap()) + .body(subscription) + .build(); + } + + /** Headers the JDK WebSocket handshake rejects or manages itself. */ + private static boolean isForwardable(String header) { + var name = header.toLowerCase(Locale.ROOT); + return !name.equals("connection") + && !name.equals("upgrade") + && !name.equals("host") + && !name.equals("content-type") + && !name.equalsIgnoreCase(Util.CONTENT_LENGTH) + && !name.startsWith("sec-websocket-"); + } + + static URI webSocketUri(String url) { + var uri = URI.create(url); + var scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws"; + return URI.create(scheme + url.substring(url.indexOf(':'))); + } + + /** + * The messages this client sends, one record per wire shape rather than per type: the + * configured {@link JsonEncoder} writes every component, so a shape carrying a component the + * protocol does not define for that message would send it as null. + */ + sealed interface ClientMessage { + + /** A bare type, covering {@code connection_init} and {@code pong}. */ + record Control(String type) implements ClientMessage {} + + /** A reference to a running operation. */ + record Complete(String id, String type) implements ClientMessage {} + + /** A reference to an operation plus the request that starts it. */ + record Subscribe(String id, String type, Operation payload) implements ClientMessage {} + } + + /** The GraphQL request a subscription starts, as feign already encoded it into the body. */ + record Operation(String query, Map variables) {} + + /** + * The envelope of a server message. Carries every component graphql-transport-ws defines, so a + * strict mapper has nothing unknown to reject. + * + * @param payload stays untyped: {@code next} carries a {@code {data, errors}} object while {@code + * error} carries a list of errors. + */ + record ServerMessage(String id, String type, Object payload) { + + @SuppressWarnings("unchecked") + Map payloadFields() { + return payload instanceof Map fields ? (Map) fields : Map.of(); + } + } + + /** + * A live subscription: the WebSocket listener, the queue of decoded {@code next} payloads and the + * {@link Response.Body} handed to {@link GraphqlDecoder} all in one, because they share a + * lifecycle. + */ + static final class Subscription implements WebSocket.Listener, Response.Body { + + private static final Object DONE = new Object(); + + /** + * Unique per connection, so a stray message for another operation is never mistaken for ours. + */ + private final String operationId = UUID.randomUUID().toString(); + + private final BlockingQueue events = new LinkedBlockingQueue<>(); + private final StringBuilder partial = new StringBuilder(); + private final Request request; + private final GraphqlContract.QueryMetadata meta; + private final JsonEncoder jsonEncoder; + private final JsonDecoder jsonDecoder; + + /** + * The already-encoded request body, decoded back so it can be sent as the subscribe payload. + */ + private final Operation operation; + + private final AtomicBoolean detached = new AtomicBoolean(); + + private volatile WebSocket webSocket; + private CompletableFuture sends = CompletableFuture.completedFuture(null); + + Subscription( + Request request, + GraphqlContract.QueryMetadata meta, + JsonEncoder jsonEncoder, + JsonDecoder jsonDecoder) + throws IOException { + this.request = request; + this.meta = meta; + this.jsonEncoder = jsonEncoder; + this.jsonDecoder = jsonDecoder; + + var charset = request.charset() == null ? UTF_8 : request.charset(); + this.operation = + request.body() == null + ? new Operation(meta.query, Map.of()) + : decode(new String(request.body(), charset), Operation.class); + } + + void attach(WebSocket webSocket) { + this.webSocket = webSocket; + } + + /** + * Hands the subscription lifecycle to the decoder, so feign closing the response body right + * after decoding no longer tears it down. + */ + void detach() { + detached.set(true); + } + + Request request() { + return request; + } + + /** + * Blocking stream of raw {@code {data, errors}} payloads, one per {@code next} message. + * + * @param timeoutMillis how long to wait for each event; {@code 0} waits indefinitely + */ + Stream> payloads(long timeoutMillis) { + return StreamSupport.stream( + Spliterators.spliteratorUnknownSize( + new PayloadIterator(timeoutMillis), Spliterator.ORDERED), + false) + .onClose(this::unsubscribe); + } + + @Override + public void onOpen(WebSocket ws) { + send(ws, new ClientMessage.Control("connection_init")); + ws.request(1); + } + + @Override + public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { + partial.append(data); + if (last) { + var text = partial.toString(); + partial.setLength(0); + handle(ws, text); + } + ws.request(1); + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + events.add(error); + } + + @Override + public CompletionStage onClose(WebSocket ws, int statusCode, String reason) { + events.add(DONE); + return null; + } + + private void handle(WebSocket ws, String text) { + ServerMessage message; + try { + message = decode(text, ServerMessage.class); + } catch (IOException | RuntimeException e) { + events.add(e); + return; + } + if (message == null || (message.id() != null && !message.id().equals(operationId))) { + return; + } + + switch (message.type()) { + case "connection_ack" -> + send(ws, new ClientMessage.Subscribe(operationId, "subscribe", operation)); + case "next" -> events.add(message.payloadFields()); + case "error" -> + events.add( + new GraphqlErrorException( + HttpURLConnection.HTTP_OK, + GraphqlContract.extractOperationField(meta.query), + String.valueOf(message.payload()), + request)); + case "complete" -> events.add(DONE); + case "ping" -> send(ws, new ClientMessage.Control("pong")); + default -> {} + } + } + + private T decode(String json, Class type) throws IOException { + var envelope = + Response.builder() + .status(HttpURLConnection.HTTP_OK) + .headers(Collections.emptyMap()) + .request(request) + .body(json, UTF_8) + .build(); + return type.cast(jsonDecoder.decode(envelope, type)); + } + + /** Sends are serialized: the JDK rejects a send while another is still in flight. */ + private synchronized void send(WebSocket ws, ClientMessage message) { + var json = toJson(message); + sends = sends.thenCompose(ignored -> ws.sendText(json, true)).thenApply(ignored -> null); + } + + private String toJson(ClientMessage message) { + var template = new RequestTemplate(); + jsonEncoder.encode(message, message.getClass(), template); + return new String(template.body(), UTF_8); + } + + void unsubscribe() { + var ws = webSocket; + events.add(DONE); + if (ws == null) { + return; + } + send(ws, new ClientMessage.Complete(operationId, "complete")); + synchronized (this) { + // whenComplete, not thenRun: the socket must close even if an earlier send failed. + sends.whenComplete((ignored, error) -> ws.sendClose(WebSocket.NORMAL_CLOSURE, "")); + } + } + + @Override + public Integer length() { + return null; + } + + @Override + public boolean isRepeatable() { + return false; + } + + @Override + public InputStream asInputStream() { + return InputStream.nullInputStream(); + } + + @Override + public Reader asReader(Charset charset) { + return Reader.nullReader(); + } + + /** + * Feign closes the response body right after decoding, which for a detached subscription is a + * no-op — the caller owns it from there. Still attached means the decoder never took ownership + * (a {@code void} method, say), so the socket is closed here rather than leaked. + */ + @Override + public void close() { + if (!detached.get()) { + unsubscribe(); + } + } + + private final class PayloadIterator implements Iterator> { + + private final long timeoutMillis; + + private Object pending; + + PayloadIterator(long timeoutMillis) { + this.timeoutMillis = timeoutMillis; + } + + @SuppressWarnings("unchecked") + @Override + public boolean hasNext() { + if (pending == null) { + pending = take(); + } + if (pending instanceof Throwable error) { + pending = DONE; + throw asUnchecked(error); + } + return pending != DONE; + } + + @SuppressWarnings("unchecked") + @Override + public Map next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + var payload = (Map) pending; + pending = null; + return payload; + } + + private Object take() { + try { + if (timeoutMillis <= 0) { + return events.take(); + } + var event = events.poll(timeoutMillis, TimeUnit.MILLISECONDS); + return event != null + ? event + : new SocketTimeoutException( + "no GraphQL subscription event within " + timeoutMillis + "ms"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return DONE; + } + } + } + + private static RuntimeException asUnchecked(Throwable error) { + if (error instanceof RuntimeException runtime) { + return runtime; + } + if (error instanceof IOException io) { + return new UncheckedIOException(io); + } + return new IllegalStateException(error); + } + } +} diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java new file mode 100644 index 000000000..ee32afed8 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java @@ -0,0 +1,420 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.jackson.JacksonCodec; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class GraphqlSubscriptionTest { + + private final ObjectMapper mapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private MockWebServer server; + + private final CountDownLatch closed = new CountDownLatch(1); + private boolean expectsWebSocket; + + public static class Price { + public String symbol; + public double price; + } + + interface StockApi { + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Stream onPrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Flow.Publisher publishPrice(String symbol); + + // blocks until the first event, then unsubscribes + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Price firstPrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Optional maybeFirstPrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + CompletableFuture futurePrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + void ignoredPrice(String symbol); + + // ordinary query on the same interface — goes over HTTP, not the web socket + @GraphqlQuery( + "query lastPrice($symbol: String!) { lastPrice(symbol: $symbol) { symbol price } }") + Price lastPrice(String symbol); + } + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + if (expectsWebSocket) { + assertThat(closed.await(10, TimeUnit.SECONDS)) + .as("client should have closed the web socket") + .isTrue(); + } + server.shutdown(); + } + + private StockApi buildClient() { + return Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper))) + .target(StockApi.class, server.url("/graphql").toString()); + } + + /** {@code {id}} is replaced with the id the client actually subscribed with. */ + private static final String NEXT = + "{\"id\":\"{id}\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":" + + "{\"symbol\":\"%s\",\"price\":%s}}}}"; + + /** Replays the graphql-transport-ws handshake, then whatever the test queued. */ + private void enqueueServer(List received, String... afterSubscribe) { + expectsWebSocket = true; + server.enqueue( + new MockResponse() + .withWebSocketUpgrade( + new WebSocketListener() { + @Override + public void onMessage(WebSocket webSocket, String text) { + received.add(text); + try { + var message = mapper.readTree(text); + if ("connection_init".equals(message.get("type").asText())) { + webSocket.send("{\"type\":\"connection_ack\"}"); + } else if ("subscribe".equals(message.get("type").asText())) { + var id = message.get("id").asText(); + for (var queued : afterSubscribe) { + webSocket.send(queued.replace("{id}", id)); + } + } + } catch (Exception e) { + throw new IllegalStateException("bad client message: " + text, e); + } + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + webSocket.close(code, reason); + closed.countDown(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + closed.countDown(); + } + })); + } + + @Test + void streamBlocksUntilEachEventArrives() throws Exception { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + String.format(NEXT, "ACME", "10.5"), + String.format(NEXT, "ACME", "11.25"), + "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + List prices; + try (var stream = buildClient().onPrice("ACME")) { + prices = stream.collect(Collectors.toList()); + } + + assertThat(prices).extracting(price -> price.symbol).containsExactly("ACME", "ACME"); + assertThat(prices).extracting(price -> price.price).containsExactly(10.5, 11.25); + + assertThat(mapper.readTree(received.get(0)).get("type").asText()).isEqualTo("connection_init"); + + var subscribe = mapper.readTree(received.get(1)); + assertThat(subscribe.get("type").asText()).isEqualTo("subscribe"); + assertThat(subscribe.get("payload").get("variables").get("symbol").asText()).isEqualTo("ACME"); + + var id = subscribe.get("id").asText(); + assertThat(UUID.fromString(id)).hasToString(id); + } + + @Test + void publisherReturnsImmediatelyAndPushes() throws Exception { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + String.format(NEXT, "ACME", "10.5"), + String.format(NEXT, "ACME", "11.25"), + "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + var publisher = buildClient().publishPrice("ACME"); + + var delivered = new ArrayList(); + var completed = new CountDownLatch(1); + publisher.subscribe( + new Flow.Subscriber() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Price item) { + delivered.add(item); + } + + @Override + public void onError(Throwable throwable) { + completed.countDown(); + } + + @Override + public void onComplete() { + completed.countDown(); + } + }); + + assertThat(completed.await(10, TimeUnit.SECONDS)).isTrue(); + assertThat(delivered).extracting(price -> price.price).containsExactly(10.5, 11.25); + } + + @Test + void serverErrorMessageFailsTheStream() { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + "{\"id\":\"{id}\",\"type\":\"error\",\"payload\":[{\"message\":\"unknown symbol\"}]}"); + + try (var stream = buildClient().onPrice("NOPE")) { + assertThatThrownBy(stream::findFirst) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("unknown symbol"); + } + } + + @Test + void errorsInsidePayloadFailTheStream() { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + "{\"id\":\"{id}\",\"type\":\"next\",\"payload\":{\"errors\":[{\"message\":\"boom\"}]}}"); + + try (var stream = buildClient().onPrice("ACME")) { + assertThatThrownBy(stream::findFirst) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("boom"); + } + } + + @Test + void plainReturnTypeBlocksForTheFirstEventOnly() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, String.format(NEXT, "ACME", "10.5"), String.format(NEXT, "ACME", "99")); + + var price = buildClient().firstPrice("ACME"); + + assertThat(price.price).isEqualTo(10.5); + } + + @Test + void optionalReturnTypeBlocksForTheFirstEvent() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, String.format(NEXT, "ACME", "10.5")); + + assertThat(buildClient().maybeFirstPrice("ACME")) + .hasValueSatisfying(price -> assertThat(price.price).isEqualTo(10.5)); + } + + @Test + void optionalReturnTypeIsEmptyWhenTheServerCompletesWithoutEvents() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + assertThat(buildClient().maybeFirstPrice("ACME")).isEmpty(); + } + + @Test + void futureReturnsImmediatelyAndCompletesWithTheFirstEvent() throws Exception { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, String.format(NEXT, "ACME", "10.5")); + + var future = buildClient().futurePrice("ACME"); + + assertThat(future.get(10, TimeUnit.SECONDS).price).isEqualTo(10.5); + } + + @Test + void voidReturnTypeClosesTheSubscription() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, String.format(NEXT, "ACME", "10.5")); + + buildClient().ignoredPrice("ACME"); + // tearDown asserts the socket was closed rather than leaked + } + + @Test + void eventTimeoutBoundsBlockingCalls() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received); + + var api = + Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(250))) + .target(StockApi.class, server.url("/graphql").toString()); + + assertThatThrownBy(() -> api.firstPrice("ACME")) + .rootCause() + .isInstanceOf(SocketTimeoutException.class); + } + + @Test + void eventTimeoutAlsoBoundsStreamElements() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received, String.format(NEXT, "ACME", "10.5")); + + var api = + Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(250))) + .target(StockApi.class, server.url("/graphql").toString()); + + try (var stream = api.onPrice("ACME")) { + // the server never completes, so the second element hits the timeout + assertThatThrownBy(stream::toList).rootCause().isInstanceOf(SocketTimeoutException.class); + } + } + + @Test + void asyncFormsAreNotBoundedByTheEventTimeout() throws Exception { + var received = new CopyOnWriteArrayList(); + enqueueServer(received); + + var api = + Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofMillis(100))) + .target(StockApi.class, server.url("/graphql").toString()); + + var future = api.futurePrice("ACME"); + + assertThatThrownBy(() -> future.get(500, TimeUnit.MILLISECONDS)) + .isInstanceOf(TimeoutException.class); + assertThat(future).isNotCompleted(); + + // deliberately still waiting on the server, so there is no close to assert on + expectsWebSocket = false; + } + + @Test + void queriesAndSubscriptionsShareOneClient() throws Exception { + server.enqueue( + new MockResponse() + .setBody("{\"data\":{\"lastPrice\":{\"symbol\":\"ACME\",\"price\":9.75}}}") + .addHeader("Content-Type", "application/json")); + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, String.format(NEXT, "ACME", "10.5"), "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + var api = buildClient(); + + assertThat(api.lastPrice("ACME").price).isEqualTo(9.75); + try (var stream = api.onPrice("ACME")) { + assertThat(stream.toList()).extracting(price -> price.price).containsExactly(10.5); + } + + var query = server.takeRequest(); + assertThat(query.getMethod()).isEqualTo("POST"); + assertThat(query.getHeader("Upgrade")).isNull(); + + var handshake = server.takeRequest(); + assertThat(handshake.getHeader("Upgrade")).isEqualToIgnoringCase("websocket"); + assertThat(handshake.getHeader("Sec-WebSocket-Protocol")).contains("graphql-transport-ws"); + } + + @Test + void eventsForAnotherOperationAreIgnored() { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + "{\"id\":\"someone-else\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":" + + "{\"symbol\":\"NOPE\",\"price\":1}}}}", + String.format(NEXT, "ACME", "10.5"), + "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + try (var stream = buildClient().onPrice("ACME")) { + assertThat(stream.toList()).extracting(price -> price.symbol).containsExactly("ACME"); + } + } + + @Test + void defaultEventTimeoutIsOneMinute() { + assertThat(GraphqlDecoder.DEFAULT_EVENT_TIMEOUT).isEqualTo(Duration.ofMinutes(1)); + } + + @Test + void subscriptionDetection() { + assertThat(GraphqlContract.isSubscription("subscription onPrice { a }")).isTrue(); + assertThat(GraphqlContract.isSubscription(" \n subscription { a }")).isTrue(); + assertThat(GraphqlContract.isSubscription("query subscriptionLike { a }")).isFalse(); + assertThat(GraphqlContract.isSubscription("mutation m { a }")).isFalse(); + } + + @Test + void webSocketUriSwapsScheme() { + assertThat(GraphqlSubscriptionClient.webSocketUri("http://host:8080/graphql")) + .hasToString("ws://host:8080/graphql"); + assertThat(GraphqlSubscriptionClient.webSocketUri("https://host/graphql")) + .hasToString("wss://host/graphql"); + } +} From afc0764fae46b4639406a9e0e1f2def81db7bfe7 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 10 Aug 2026 19:43:48 -0300 Subject: [PATCH 09/45] Bound GraphQL subscription reads, threads and send failures Signed-off-by: Marvin Froeder --- graphql/README.md | 13 +- .../java/feign/graphql/GraphqlCapability.java | 45 ++++++- .../java/feign/graphql/GraphqlDecoder.java | 62 +++++----- .../graphql/GraphqlSubscriptionClient.java | 112 +++++++++++++----- .../graphql/GraphqlSubscriptionTest.java | 43 +++++++ 5 files changed, 213 insertions(+), 62 deletions(-) diff --git a/graphql/README.md b/graphql/README.md index dc4c05634..11715fbc5 100644 --- a/graphql/README.md +++ b/graphql/README.md @@ -163,7 +163,18 @@ subscription that can legitimately sit idle for longer needs `Duration.ZERO`. `Flow.Publisher` and `CompletableFuture` are deliberately *not* bounded by it — their caller already owns the deadline, via cancelling the subscription or -`get(timeout, unit)`/`orTimeout(...)`. +`get(timeout, unit)`/`orTimeout(...)`. Cancelling either one closes the underlying WebSocket. + +Those two asynchronous forms each need a worker for as long as the subscription is open. They run on +a bounded daemon pool by default; pass your own to own the lifecycle: + +```java +new GraphqlCapability(new JacksonCodec(), Duration.ofSeconds(5), myExecutor) +``` + +Reads are demand-driven — the client asks the socket for another frame only once the consumer has +taken the previous event — so a slow consumer applies backpressure to the server instead of growing +a queue in memory. `Flow.Publisher` is `java.util.concurrent.Flow.Publisher`, so it plugs into Reactor (`JdkFlowAdapter.flowPublisherToFlux`) or RxJava (`Flowable.fromPublisher`) without extra diff --git a/graphql/src/main/java/feign/graphql/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java index 1e24bc778..a8d5c0298 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlCapability.java +++ b/graphql/src/main/java/feign/graphql/GraphqlCapability.java @@ -27,6 +27,11 @@ import feign.codec.JsonEncoder; import java.time.Duration; import java.util.ArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; @Experimental public class GraphqlCapability implements Capability { @@ -52,6 +57,15 @@ public GraphqlCapability(JsonCodec codec, Duration eventTimeout) { this(codec.encoder(), codec.decoder(), eventTimeout); } + /** + * @param executor runs the worker behind each {@code Flow.Publisher} and {@code + * CompletableFuture} subscription, and delivers to their subscribers. Supply your own to own + * the lifecycle; the default is bounded and daemon, and is never shut down. + */ + public GraphqlCapability(JsonCodec codec, Duration eventTimeout, Executor executor) { + this(codec.encoder(), codec.decoder(), eventTimeout, executor); + } + public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) { this(encoder, decoder, GraphqlDecoder.DEFAULT_EVENT_TIMEOUT); } @@ -60,13 +74,42 @@ public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder) { * @param eventTimeout see {@link #GraphqlCapability(JsonCodec, Duration)} */ public GraphqlCapability(JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout) { + this(encoder, decoder, eventTimeout, defaultExecutor()); + } + + /** + * @param executor see {@link #GraphqlCapability(JsonCodec, Duration, Executor)} + */ + public GraphqlCapability( + JsonEncoder encoder, JsonDecoder decoder, Duration eventTimeout, Executor executor) { this.graphqlEncoder = new GraphqlEncoder(encoder, contract); - this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout); + this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout, executor); this.interceptor = new GraphqlRequestInterceptor(encoder, contract); this.jsonEncoder = encoder; this.jsonDecoder = decoder; } + /** + * Bounded and daemon, so a runaway subscription count fails fast rather than exhausting threads. + * A synchronous handoff queue is deliberate: subscription workers are long-lived, so queueing + * them would hide exhaustion until the heap gave out. + */ + private static Executor defaultExecutor() { + var threads = new AtomicLong(); + return new ThreadPoolExecutor( + 0, + Math.max(8, Runtime.getRuntime().availableProcessors() * 4), + 60L, + TimeUnit.SECONDS, + new SynchronousQueue<>(), + runnable -> { + var thread = + new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); + } + @Override public Contract enrich(Contract contract) { return this.contract; diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java index daada7143..b5720f676 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -32,8 +32,10 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; import java.util.concurrent.Flow; import java.util.concurrent.SubmissionPublisher; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; @Experimental @@ -44,17 +46,19 @@ public class GraphqlDecoder implements Decoder { private final JsonDecoder jsonDecoder; private final long eventTimeoutMillis; + private final Executor executor; public GraphqlDecoder(JsonDecoder jsonDecoder) { - this(jsonDecoder, DEFAULT_EVENT_TIMEOUT); + this(jsonDecoder, DEFAULT_EVENT_TIMEOUT, Runnable::run); } - public GraphqlDecoder(JsonDecoder jsonDecoder, Duration eventTimeout) { + public GraphqlDecoder(JsonDecoder jsonDecoder, Duration eventTimeout, Executor executor) { if (eventTimeout.isNegative()) { throw new IllegalArgumentException("eventTimeout must not be negative: " + eventTimeout); } this.jsonDecoder = jsonDecoder; this.eventTimeoutMillis = eventTimeout.toMillis(); + this.executor = executor; } @Override @@ -199,7 +203,14 @@ private Optional first(Subscription subscription, Type elementType, long private CompletableFuture futureOf(Subscription subscription, Type elementType) { var future = new CompletableFuture<>(); - pump( + // Cancelling must reach the socket, or a cancelled future leaks the connection and its worker. + future.whenComplete( + (ignored, error) -> { + if (future.isCancelled()) { + subscription.unsubscribe(); + } + }); + executor.execute( () -> { try { future.complete(first(subscription, elementType, 0).orElse(null)); @@ -224,33 +235,28 @@ private Stream elements(Subscription subscription, Type elementType, lon } private Flow.Publisher publish(Subscription subscription, Type elementType) { - var publisher = new SubmissionPublisher(); - pump( - () -> { - try (var elements = elements(subscription, elementType, 0)) { - var iterator = elements.iterator(); - var subscribed = false; - while (iterator.hasNext()) { - publisher.submit(iterator.next()); - // hasSubscribers() is also false before the first subscribe arrives, hence the - // latch — buffered elements hold until then. - if (subscribed && !publisher.hasSubscribers()) { - break; + var publisher = new SubmissionPublisher<>(executor, Flow.defaultBufferSize()); + var started = new AtomicBoolean(); + // Pumping starts on the first subscribe, so hasSubscribers() is meaningful from the first + // element onwards and there is no pre-subscribe window to latch around. + return subscriber -> { + publisher.subscribe(subscriber); + if (!started.compareAndSet(false, true)) { + return; + } + executor.execute( + () -> { + try (var elements = elements(subscription, elementType, 0)) { + var iterator = elements.iterator(); + while (iterator.hasNext() && publisher.hasSubscribers()) { + publisher.submit(iterator.next()); } - subscribed |= publisher.hasSubscribers(); + publisher.close(); + } catch (Throwable e) { + publisher.closeExceptionally(e); } - publisher.close(); - } catch (Throwable e) { - publisher.closeExceptionally(e); - } - }); - return publisher; - } - - private static void pump(Runnable task) { - var thread = new Thread(task, "feign-graphql-subscription"); - thread.setDaemon(true); - thread.start(); + }); + }; } private static boolean isRawType(Type type, Class raw) { diff --git a/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java index df7426c51..f7ddad129 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java +++ b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java @@ -27,6 +27,7 @@ import feign.codec.JsonEncoder; import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; import java.io.Reader; import java.io.UncheckedIOException; import java.net.HttpURLConnection; @@ -204,7 +205,9 @@ static final class Subscription implements WebSocket.Listener, Response.Body { */ private final String operationId = UUID.randomUUID().toString(); - private final BlockingQueue events = new LinkedBlockingQueue<>(); + /** Bounded: demand-driven reads keep this near empty, the capacity is a safety net. */ + private final BlockingQueue events = new LinkedBlockingQueue<>(1024); + private final StringBuilder partial = new StringBuilder(); private final Request request; private final GraphqlContract.QueryMetadata meta; @@ -217,6 +220,7 @@ static final class Subscription implements WebSocket.Listener, Response.Body { private final Operation operation; private final AtomicBoolean detached = new AtomicBoolean(); + private final AtomicBoolean unsubscribed = new AtomicBoolean(); private volatile WebSocket webSocket; private CompletableFuture sends = CompletableFuture.completedFuture(null); @@ -277,53 +281,74 @@ public void onOpen(WebSocket ws) { @Override public CompletionStage onText(WebSocket ws, CharSequence data, boolean last) { partial.append(data); - if (last) { - var text = partial.toString(); - partial.setLength(0); - handle(ws, text); + if (!last) { + ws.request(1); + return null; + } + var text = partial.toString(); + partial.setLength(0); + // Only control frames pull the next one eagerly. A queued payload waits for the consumer to + // take it, which is what bounds the queue. + if (!handle(ws, text)) { + ws.request(1); } - ws.request(1); return null; } @Override public void onError(WebSocket ws, Throwable error) { - events.add(error); + publish(error); } @Override public CompletionStage onClose(WebSocket ws, int statusCode, String reason) { - events.add(DONE); + publish(DONE); return null; } - private void handle(WebSocket ws, String text) { + /** + * @return true when this message queued an event, so the next frame waits for the consumer. + */ + private boolean handle(WebSocket ws, String text) { ServerMessage message; try { message = decode(text, ServerMessage.class); } catch (IOException | RuntimeException e) { - events.add(e); - return; + return publish(e); } if (message == null || (message.id() != null && !message.id().equals(operationId))) { - return; + return false; } - switch (message.type()) { - case "connection_ack" -> - send(ws, new ClientMessage.Subscribe(operationId, "subscribe", operation)); - case "next" -> events.add(message.payloadFields()); + return switch (message.type()) { + case "connection_ack" -> { + send(ws, new ClientMessage.Subscribe(operationId, "subscribe", operation)); + yield false; + } + case "next" -> publish(message.payloadFields()); case "error" -> - events.add( + publish( new GraphqlErrorException( HttpURLConnection.HTTP_OK, GraphqlContract.extractOperationField(meta.query), String.valueOf(message.payload()), request)); - case "complete" -> events.add(DONE); - case "ping" -> send(ws, new ClientMessage.Control("pong")); - default -> {} + case "complete" -> publish(DONE); + case "ping" -> { + send(ws, new ClientMessage.Control("pong")); + yield false; + } + default -> false; + }; + } + + private boolean publish(Object event) { + if (!events.offer(event)) { + // Unreachable while reads are demand-driven; failing loudly beats growing without bound. + events.clear(); + events.offer(new IllegalStateException("GraphQL subscription event queue overflowed")); } + return true; } private T decode(String json, Class type) throws IOException { @@ -337,10 +362,22 @@ private T decode(String json, Class type) throws IOException { return type.cast(jsonDecoder.decode(envelope, type)); } - /** Sends are serialized: the JDK rejects a send while another is still in flight. */ + /** + * Sends are serialized: the JDK rejects a send while another is still in flight. A failure is + * surfaced to the consumer and the chain reset, so it cannot silently swallow later sends. + */ private synchronized void send(WebSocket ws, ClientMessage message) { var json = toJson(message); - sends = sends.thenCompose(ignored -> ws.sendText(json, true)).thenApply(ignored -> null); + sends = + sends + .thenCompose(ignored -> ws.sendText(json, true)) + .handle( + (ignored, error) -> { + if (error != null) { + publish(error); + } + return null; + }); } private String toJson(ClientMessage message) { @@ -350,13 +387,17 @@ private String toJson(ClientMessage message) { } void unsubscribe() { + if (!unsubscribed.compareAndSet(false, true)) { + return; + } var ws = webSocket; - events.add(DONE); + publish(DONE); if (ws == null) { return; } - send(ws, new ClientMessage.Complete(operationId, "complete")); + ws.request(1); // let the closing handshake be delivered synchronized (this) { + send(ws, new ClientMessage.Complete(operationId, "complete")); // whenComplete, not thenRun: the socket must close even if an earlier send failed. sends.whenComplete((ignored, error) -> ws.sendClose(WebSocket.NORMAL_CLOSURE, "")); } @@ -430,17 +471,24 @@ public Map next() { private Object take() { try { - if (timeoutMillis <= 0) { - return events.take(); + var event = + timeoutMillis <= 0 + ? events.take() + : events.poll(timeoutMillis, TimeUnit.MILLISECONDS); + if (event == null) { + return new SocketTimeoutException( + "no GraphQL subscription event within " + timeoutMillis + "ms"); + } + if (event != DONE) { + var ws = webSocket; + if (ws != null) { + ws.request(1); // consuming an event is what authorises the next read + } } - var event = events.poll(timeoutMillis, TimeUnit.MILLISECONDS); - return event != null - ? event - : new SocketTimeoutException( - "no GraphQL subscription event within " + timeoutMillis + "ms"); + return event; } catch (InterruptedException e) { Thread.currentThread().interrupt(); - return DONE; + return new InterruptedIOException("interrupted awaiting a GraphQL subscription event"); } } } diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java index ee32afed8..22c4b6370 100644 --- a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java @@ -397,6 +397,49 @@ void eventsForAnotherOperationAreIgnored() { } } + @Test + void slowConsumerStillReceivesEveryEvent() { + var received = new CopyOnWriteArrayList(); + enqueueServer( + received, + String.format(NEXT, "ACME", "1"), + String.format(NEXT, "ACME", "2"), + String.format(NEXT, "ACME", "3"), + String.format(NEXT, "ACME", "4"), + String.format(NEXT, "ACME", "5"), + "{\"id\":\"{id}\",\"type\":\"complete\"}"); + + // Reads are demand-driven, so a consumer that lags must still be handed every event in order. + // Broken demand accounting stalls here until the event timeout instead. + List prices; + try (var stream = buildClient().onPrice("ACME")) { + prices = + stream + .peek( + price -> { + try { + Thread.sleep(20); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }) + .toList(); + } + + assertThat(prices).extracting(price -> price.price).containsExactly(1.0, 2.0, 3.0, 4.0, 5.0); + } + + @Test + void cancellingTheFutureClosesTheSubscription() { + var received = new CopyOnWriteArrayList(); + enqueueServer(received); + + var future = buildClient().futurePrice("ACME"); + assertThat(future.cancel(true)).isTrue(); + + // tearDown asserts the web socket was closed rather than left hanging on a cancelled future + } + @Test void defaultEventTimeoutIsOneMinute() { assertThat(GraphqlDecoder.DEFAULT_EVENT_TIMEOUT).isEqualTo(Duration.ofMinutes(1)); From 7e07edb07104319ca3f4240d41e959c54385018f Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Mon, 10 Aug 2026 19:53:07 -0300 Subject: [PATCH 10/45] Add concurrency test for GraphQL subscriptions Signed-off-by: Marvin Froeder --- .../GraphqlSubscriptionConcurrencyTest.java | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java new file mode 100644 index 000000000..55d90f110 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java @@ -0,0 +1,281 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.graphql; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import feign.Feign; +import feign.jackson.JacksonCodec; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executors; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import java.util.stream.Stream; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okhttp3.mockwebserver.Dispatcher; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Exercises the subscription wiring under concurrent load: many sockets open at once, sharing one + * capability, one JSON codec and one worker pool. + */ +class GraphqlSubscriptionConcurrencyTest { + + private static final int SUBSCRIPTIONS = 24; + private static final int EVENTS_EACH = 20; + + private final ObjectMapper mapper = + new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + private MockWebServer server; + + /** Counts sockets the client closed, so leaks show up as a shortfall. */ + private final CountDownLatch closed = new CountDownLatch(SUBSCRIPTIONS); + + private final AtomicInteger openSockets = new AtomicInteger(); + + public static class Price { + public String symbol; + public double price; + } + + interface StockApi { + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Stream onPrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + Flow.Publisher publishPrice(String symbol); + } + + @BeforeEach + void setUp() throws Exception { + server = new MockWebServer(); + // A dispatcher rather than a queue: every connection gets its own upgrade and its own listener, + // so the subscriptions are genuinely independent sockets. + server.setDispatcher( + new Dispatcher() { + @Override + public MockResponse dispatch(RecordedRequest request) { + return new MockResponse().withWebSocketUpgrade(new EchoingServer()); + } + }); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.shutdown(); + } + + /** Replays the handshake, then emits the requested symbol back with the client's own id. */ + private final class EchoingServer extends WebSocketListener { + + @Override + public void onOpen(WebSocket webSocket, Response response) { + openSockets.incrementAndGet(); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + try { + var message = mapper.readTree(text); + var type = message.get("type").asText(); + if ("connection_init".equals(type)) { + webSocket.send("{\"type\":\"connection_ack\"}"); + return; + } + if (!"subscribe".equals(type)) { + return; + } + var id = message.get("id").asText(); + var symbol = message.get("payload").get("variables").get("symbol").asText(); + for (var i = 0; i < EVENTS_EACH; i++) { + webSocket.send( + "{\"id\":\"" + + id + + "\",\"type\":\"next\",\"payload\":{\"data\":{\"priceChanged\":{\"symbol\":\"" + + symbol + + "\",\"price\":" + + i + + "}}}}"); + } + webSocket.send("{\"id\":\"" + id + "\",\"type\":\"complete\"}"); + } catch (Exception e) { + throw new IllegalStateException("bad client message: " + text, e); + } + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + webSocket.close(code, reason); + closed.countDown(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { + closed.countDown(); + } + } + + private StockApi buildClient() { + return Feign.builder() + .addCapability(new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30))) + .target(StockApi.class, server.url("/graphql").toString()); + } + + private List runAllAtOnce(List> tasks) throws Exception { + var pool = Executors.newFixedThreadPool(tasks.size()); + try { + var barrier = new CyclicBarrier(tasks.size()); + var futures = + tasks.stream() + .map( + task -> + pool.submit( + () -> { + barrier.await(30, TimeUnit.SECONDS); + return task.call(); + })) + .toList(); + var results = new java.util.ArrayList(); + for (var future : futures) { + results.add(future.get(60, TimeUnit.SECONDS)); + } + return results; + } finally { + pool.shutdownNow(); + } + } + + @Test + void concurrentStreamsStayIsolated() throws Exception { + var api = buildClient(); + + List>> tasks = + IntStream.range(0, SUBSCRIPTIONS) + .>>mapToObj( + index -> + () -> { + try (var prices = api.onPrice("SYM" + index)) { + return prices.toList(); + } + }) + .toList(); + + var results = runAllAtOnce(tasks); + + // Every subscription sees exactly its own events, in order, with nothing from its neighbours. + for (var index = 0; index < SUBSCRIPTIONS; index++) { + var prices = results.get(index); + assertThat(prices).hasSize(EVENTS_EACH); + assertThat(prices).extracting(price -> price.symbol).containsOnly("SYM" + index); + assertThat(prices) + .extracting(price -> price.price) + .containsExactlyElementsOf( + IntStream.range(0, EVENTS_EACH).mapToObj(i -> (double) i).toList()); + } + + assertThat(openSockets).hasValue(SUBSCRIPTIONS); + assertThat(closed.await(30, TimeUnit.SECONDS)) + .as("every socket should have been closed, not leaked") + .isTrue(); + } + + @Test + void concurrentPublishersDeliverEveryEvent() throws Exception { + var api = buildClient(); + + List> tasks = + IntStream.range(0, SUBSCRIPTIONS) + .>mapToObj( + index -> + () -> { + var delivered = new AtomicInteger(); + var done = new CountDownLatch(1); + api.publishPrice("SYM" + index) + .subscribe( + new Flow.Subscriber() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Price item) { + delivered.incrementAndGet(); + } + + @Override + public void onError(Throwable throwable) { + done.countDown(); + } + + @Override + public void onComplete() { + done.countDown(); + } + }); + assertThat(done.await(60, TimeUnit.SECONDS)).isTrue(); + return delivered.get(); + }) + .toList(); + + assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH); + assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue(); + } + + @Test + void closingMidStreamFromAnotherThreadTerminatesPromptly() throws Exception { + var api = buildClient(); + + List> tasks = + IntStream.range(0, SUBSCRIPTIONS) + .>mapToObj( + index -> + () -> { + // Take a couple of events and walk away while the server is still pushing. + try (var prices = api.onPrice("SYM" + index)) { + return prices.limit(2).toList().size(); + } + }) + .toList(); + + assertThat(runAllAtOnce(tasks)).containsOnly(2); + assertThat(closed.await(30, TimeUnit.SECONDS)) + .as("abandoning a stream must still close its socket") + .isTrue(); + } +} From 603e8ccd71f539b14f053c3d013d5ab4669fbaf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:12:47 +0000 Subject: [PATCH 11/45] build(deps): Bump org.junit:junit-bom from 6.1.2 to 6.1.3 Bumps [org.junit:junit-bom](https://github.com/junit-team/junit-framework) from 6.1.2 to 6.1.3. - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.1.2...r6.1.3) --- updated-dependencies: - dependency-name: org.junit:junit-bom dependency-version: 6.1.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 5b9a274c0..6c24ae88e 100644 --- a/pom.xml +++ b/pom.xml @@ -173,7 +173,7 @@ 20260719 4.1.0 - 6.1.2 + 6.1.3 2.22.1 3.2.1 3.27.7 From 3846112d30d50490d3e7d5bd99d212fa3dd37f7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:13:55 +0000 Subject: [PATCH 12/45] build(deps-dev): Bump vertx.version in /vertx/feign-vertx4-test Bumps `vertx.version` from 4.5.31 to 4.5.32. Updates `io.vertx:vertx-junit5` from 4.5.31 to 4.5.32 - [Commits](https://github.com/eclipse-vertx/vertx-junit5/compare/4.5.31...4.5.32) Updates `io.vertx:vertx-web-client` from 4.5.31 to 4.5.32 - [Commits](https://github.com/vert-x3/vertx-web/compare/4.5.31...4.5.32) --- updated-dependencies: - dependency-name: io.vertx:vertx-junit5 dependency-version: 4.5.32 dependency-type: direct:development update-type: version-update:semver-patch - dependency-name: io.vertx:vertx-web-client dependency-version: 4.5.32 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- vertx/feign-vertx4-test/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 87835bfa3..0b13e3f8f 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -30,7 +30,7 @@ Tests with Vertx 4.x. - 4.5.31 + 4.5.32 From 3577c002d4ec1aa59cedc6b4ba72c3aa6f7c4e5d Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Tue, 11 Aug 2026 06:56:53 -0300 Subject: [PATCH 13/45] Fix subscription worker starvation under a small thread pool Signed-off-by: Marvin Froeder --- .../java/feign/graphql/GraphqlCapability.java | 18 +-- .../java/feign/graphql/GraphqlDecoder.java | 54 +++++---- .../GraphqlSubscriptionConcurrencyTest.java | 109 ++++++++++++++++++ 3 files changed, 149 insertions(+), 32 deletions(-) diff --git a/graphql/src/main/java/feign/graphql/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java index a8d5c0298..3b0970e55 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlCapability.java +++ b/graphql/src/main/java/feign/graphql/GraphqlCapability.java @@ -28,9 +28,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.concurrent.Executor; -import java.util.concurrent.SynchronousQueue; -import java.util.concurrent.ThreadPoolExecutor; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicLong; @Experimental @@ -90,18 +88,14 @@ public GraphqlCapability( } /** - * Bounded and daemon, so a runaway subscription count fails fast rather than exhausting threads. - * A synchronous handoff queue is deliberate: subscription workers are long-lived, so queueing - * them would hide exhaustion until the heap gave out. + * Each open {@code Flow.Publisher} or {@code CompletableFuture} subscription holds one worker for + * its lifetime, so the default pool grows on demand and reaps idle threads rather than capping + * concurrent subscriptions at a guess. Supply a bounded executor to cap them deliberately: the + * excess is refused with {@code RejectedExecutionException} rather than left hanging. */ private static Executor defaultExecutor() { var threads = new AtomicLong(); - return new ThreadPoolExecutor( - 0, - Math.max(8, Runtime.getRuntime().availableProcessors() * 4), - 60L, - TimeUnit.SECONDS, - new SynchronousQueue<>(), + return Executors.newCachedThreadPool( runnable -> { var thread = new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet()); diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java index b5720f676..e28a8d691 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -34,6 +34,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executor; import java.util.concurrent.Flow; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.SubmissionPublisher; import java.util.concurrent.atomic.AtomicBoolean; import java.util.stream.Stream; @@ -210,14 +211,19 @@ private CompletableFuture futureOf(Subscription subscription, Type eleme subscription.unsubscribe(); } }); - executor.execute( - () -> { - try { - future.complete(first(subscription, elementType, 0).orElse(null)); - } catch (Throwable e) { - future.completeExceptionally(e); - } - }); + try { + executor.execute( + () -> { + try { + future.complete(first(subscription, elementType, 0).orElse(null)); + } catch (Throwable e) { + future.completeExceptionally(e); + } + }); + } catch (RejectedExecutionException e) { + subscription.unsubscribe(); + future.completeExceptionally(e); + } return future; } @@ -235,7 +241,9 @@ private Stream elements(Subscription subscription, Type elementType, lon } private Flow.Publisher publish(Subscription subscription, Type elementType) { - var publisher = new SubmissionPublisher<>(executor, Flow.defaultBufferSize()); + // Runnable::run delivers on the pump thread: one worker per subscription in total, delivery can + // never be rejected by a busy pool, and onNext is inherently ordered. + var publisher = new SubmissionPublisher<>(Runnable::run, Flow.defaultBufferSize()); var started = new AtomicBoolean(); // Pumping starts on the first subscribe, so hasSubscribers() is meaningful from the first // element onwards and there is no pre-subscribe window to latch around. @@ -244,18 +252,24 @@ private Flow.Publisher publish(Subscription subscription, Type elementTy if (!started.compareAndSet(false, true)) { return; } - executor.execute( - () -> { - try (var elements = elements(subscription, elementType, 0)) { - var iterator = elements.iterator(); - while (iterator.hasNext() && publisher.hasSubscribers()) { - publisher.submit(iterator.next()); + try { + executor.execute( + () -> { + try (var elements = elements(subscription, elementType, 0)) { + var iterator = elements.iterator(); + while (iterator.hasNext() && publisher.hasSubscribers()) { + publisher.submit(iterator.next()); + } + publisher.close(); + } catch (Throwable e) { + publisher.closeExceptionally(e); } - publisher.close(); - } catch (Throwable e) { - publisher.closeExceptionally(e); - } - }); + }); + } catch (RejectedExecutionException e) { + // A subscriber must always get a terminal signal; stranding it is worse than failing it. + subscription.unsubscribe(); + publisher.closeExceptionally(e); + } }; } diff --git a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java index 55d90f110..1236c88a5 100644 --- a/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java @@ -24,10 +24,14 @@ import java.time.Duration; import java.util.List; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; import java.util.concurrent.Flow; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.IntStream; @@ -62,6 +66,11 @@ class GraphqlSubscriptionConcurrencyTest { private final AtomicInteger openSockets = new AtomicInteger(); + /** + * When false the server acknowledges the subscribe and then stays quiet, as a real feed would. + */ + private volatile boolean emitEvents = true; + public static class Price { public String symbol; public double price; @@ -78,6 +87,11 @@ interface StockApi { "subscription onPrice($symbol: String!) {" + " priceChanged(symbol: $symbol) { symbol price } }") Flow.Publisher publishPrice(String symbol); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + CompletableFuture futurePrice(String symbol); } @BeforeEach @@ -120,6 +134,9 @@ public void onMessage(WebSocket webSocket, String text) { if (!"subscribe".equals(type)) { return; } + if (!emitEvents) { + return; + } var id = message.get("id").asText(); var symbol = message.get("payload").get("variables").get("symbol").asText(); for (var i = 0; i < EVENTS_EACH; i++) { @@ -156,6 +173,42 @@ private StockApi buildClient() { .target(StockApi.class, server.url("/graphql").toString()); } + private StockApi buildClient(Executor executor) { + return Feign.builder() + .addCapability( + new GraphqlCapability(new JacksonCodec(mapper), Duration.ofSeconds(30), executor)) + .target(StockApi.class, server.url("/graphql").toString()); + } + + private int drain(Flow.Publisher publisher) throws Exception { + var delivered = new AtomicInteger(); + var done = new CountDownLatch(1); + publisher.subscribe( + new Flow.Subscriber() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Price item) { + delivered.incrementAndGet(); + } + + @Override + public void onError(Throwable throwable) { + done.countDown(); + } + + @Override + public void onComplete() { + done.countDown(); + } + }); + assertThat(done.await(60, TimeUnit.SECONDS)).isTrue(); + return delivered.get(); + } + private List runAllAtOnce(List> tasks) throws Exception { var pool = Executors.newFixedThreadPool(tasks.size()); try { @@ -257,6 +310,62 @@ public void onComplete() { assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue(); } + @Test + void aPoolSizedForTheSubscriptionsIsEnough() throws Exception { + // One worker per open subscription is the documented cost. Needing a second thread per + // subscription for delivery would starve this pool and hang instead. + var pool = Executors.newFixedThreadPool(SUBSCRIPTIONS); + try { + var api = buildClient(pool); + List> tasks = + IntStream.range(0, SUBSCRIPTIONS) + .>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index))) + .toList(); + + assertThat(runAllAtOnce(tasks)).containsOnly(EVENTS_EACH); + } finally { + pool.shutdownNow(); + } + } + + @Test + void aPoolTooSmallRefusesRatherThanStranding() throws Exception { + var pool = new ThreadPoolExecutor(0, 4, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + try { + var api = buildClient(pool); + List> tasks = + IntStream.range(0, SUBSCRIPTIONS) + .>mapToObj(index -> () -> drain(api.publishPrice("SYM" + index))) + .toList(); + + // Far more subscriptions than workers. Some are refused, but every subscriber must reach a + // terminal signal — drain() asserts that. Leaving one waiting forever is the failure mode. + assertThat(runAllAtOnce(tasks)).hasSize(SUBSCRIPTIONS); + } finally { + pool.shutdownNow(); + } + } + + @Test + void longLivedSubscriptionsAreNotCappedByCoreCount() throws Exception { + emitEvents = false; + var api = buildClient(); + + // Each of these holds its worker parked on the queue for as long as it is open, which is what a + // real feed does. A pool sized from the core count would refuse the excess synchronously. + var futures = + IntStream.range(0, SUBSCRIPTIONS) + .mapToObj(index -> api.futurePrice("SYM" + index)) + .toList(); + + assertThat(futures) + .as("no subscription should have been refused a worker") + .allSatisfy(future -> assertThat(future).isNotCompleted()); + + futures.forEach(future -> future.cancel(true)); + assertThat(closed.await(30, TimeUnit.SECONDS)).isTrue(); + } + @Test void closingMidStreamFromAnotherThreadTerminatesPromptly() throws Exception { var api = buildClient(); From 80a8ac7b090636d86dc9371f7d06b6c3d5b33e3a Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Tue, 11 Aug 2026 12:08:43 -0300 Subject: [PATCH 14/45] Use a version property for flatten-maven-plugin and keep sortpom on the committed feign-bom POM Signed-off-by: Marvin Froeder --- feign-bom/pom.xml | 15 ++++++++++++--- pom.xml | 1 + src/config/bom.xml | 15 ++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index 56db0a4a6..c2e7a464f 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -269,7 +269,7 @@ org.codehaus.mojo flatten-maven-plugin - 1.7.3 + ${flatten-maven-plugin.version} bom @@ -279,20 +279,29 @@ flatten - process-resources flatten + process-resources flatten.clean - clean clean + clean + + com.github.ekryd.sortpom + sortpom-maven-plugin + + + ${project.basedir}/pom.xml + + diff --git a/pom.xml b/pom.xml index 62ff5e96f..7421ec840 100644 --- a/pom.xml +++ b/pom.xml @@ -205,6 +205,7 @@ 3.3.0 1.2.8 4.0.0 + 1.7.3 6.45.0 3.43.0 3.41.0 diff --git a/src/config/bom.xml b/src/config/bom.xml index 1d903eaef..30ee18a64 100644 --- a/src/config/bom.xml +++ b/src/config/bom.xml @@ -72,7 +72,7 @@ org.codehaus.mojo flatten-maven-plugin - 1.7.3 + ${flatten-maven-plugin.version} bom @@ -82,20 +82,29 @@ flatten - process-resources flatten + process-resources flatten.clean - clean clean + clean + + com.github.ekryd.sortpom + sortpom-maven-plugin + + + ${project.basedir}/pom.xml + + From 60e75ad4b0a9f9d2934ae05c7b45d85c91822aa2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:10:57 +0000 Subject: [PATCH 15/45] build(deps-dev): Bump org.codehaus.mojo:flatten-maven-plugin Bumps [org.codehaus.mojo:flatten-maven-plugin](https://github.com/mojohaus/flatten-maven-plugin) from 1.7.3 to 1.8.0. - [Release notes](https://github.com/mojohaus/flatten-maven-plugin/releases) - [Commits](https://github.com/mojohaus/flatten-maven-plugin/compare/1.7.3...1.8.0) --- updated-dependencies: - dependency-name: org.codehaus.mojo:flatten-maven-plugin dependency-version: 1.8.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 7e7fc497a..c4f4ee878 100644 --- a/pom.xml +++ b/pom.xml @@ -205,7 +205,7 @@ 3.3.0 1.2.8 4.0.0 - 1.7.3 + 1.8.0 6.45.0 3.43.0 3.41.0 From 75b7fd38cdbdde4910e5fa6d4d022d40fe9d4602 Mon Sep 17 00:00:00 2001 From: Abdullah <89297042+AzazelSensei@users.noreply.github.com> Date: Wed, 12 Aug 2026 08:35:58 +0000 Subject: [PATCH 16/45] Skip synthetic and bridge methods in contract parsing Covariant overrides generate unannotated bridge methods that BaseContract previously tried to parse, failing inheritance tests such as overrideParameterizedApiSupported. Fixes #2752 --- core/src/main/java/feign/Contract.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java index 516272f45..3501993c6 100644 --- a/core/src/main/java/feign/Contract.java +++ b/core/src/main/java/feign/Contract.java @@ -60,7 +60,9 @@ public List parseAndValidateMetadata(Class targetType) { if (method.getDeclaringClass() == Object.class || (method.getModifiers() & Modifier.STATIC) != 0 || Util.isDefault(method) - || method.isAnnotationPresent(FeignIgnore.class)) { + || method.isAnnotationPresent(FeignIgnore.class) + || method.isSynthetic() + || method.isBridge()) { continue; } final MethodMetadata metadata = parseAndValidateMetadata(targetType, method); From e0fd29f0d267cf9615d7a815312f788ec5c61c12 Mon Sep 17 00:00:00 2001 From: Abdullah Date: Wed, 12 Aug 2026 14:09:06 +0000 Subject: [PATCH 17/45] Map bridge methods to bridged handlers in ReflectiveFeign Skipping bridges in BaseContract left generic super-interface calls (e.g. CrudApi via UserApi) without a dispatch entry. Resolve each bridge Method to the handler of the method it bridges to, and cover that path in BridgeMethodTest. --- core/src/main/java/feign/ReflectiveFeign.java | 58 +++++++++++++++ .../src/test/java/feign/BridgeMethodTest.java | 71 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 core/src/test/java/feign/BridgeMethodTest.java diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java index 31b011087..21a874bcc 100644 --- a/core/src/main/java/feign/ReflectiveFeign.java +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -155,9 +155,67 @@ public Map apply(Target target, C requestContext) { } } + for (Method method : target.type().getMethods()) { + if (!method.isBridge()) { + continue; + } + Method bridged = resolveBridgedMethod(method); + MethodHandler handler = result.get(bridged); + if (handler != null) { + result.put(method, handler); + } + } + return result; } + static Method resolveBridgedMethod(Method bridgeMethod) { + Method matched = null; + Class[] bridgeParams = bridgeMethod.getParameterTypes(); + for (Method candidate : bridgeMethod.getDeclaringClass().getDeclaredMethods()) { + if (candidate.isBridge() + || candidate.isSynthetic() + || !candidate.getName().equals(bridgeMethod.getName()) + || candidate.getParameterCount() != bridgeParams.length) { + continue; + } + Class[] candidateParams = candidate.getParameterTypes(); + boolean paramsMatch = true; + for (int i = 0; i < bridgeParams.length; i++) { + if (!bridgeParams[i].isAssignableFrom(candidateParams[i])) { + paramsMatch = false; + break; + } + } + if (!paramsMatch) { + continue; + } + Class bridgeReturn = bridgeMethod.getReturnType(); + Class candidateReturn = candidate.getReturnType(); + if (bridgeReturn != void.class && !bridgeReturn.isAssignableFrom(candidateReturn)) { + continue; + } + if (matched == null || isMoreSpecific(candidate, matched)) { + matched = candidate; + } + } + return matched != null ? matched : bridgeMethod; + } + + private static boolean isMoreSpecific(Method candidate, Method current) { + Class[] candidateParams = candidate.getParameterTypes(); + Class[] currentParams = current.getParameterTypes(); + for (int i = 0; i < candidateParams.length; i++) { + if (candidateParams[i] != currentParams[i] + && currentParams[i].isAssignableFrom(candidateParams[i])) { + return true; + } + } + Class candidateReturn = candidate.getReturnType(); + Class currentReturn = current.getReturnType(); + return candidateReturn != currentReturn && currentReturn.isAssignableFrom(candidateReturn); + } + private MethodHandler createMethodHandler( final Target target, final MethodMetadata md, final C requestContext) { if (md.isIgnored()) { diff --git a/core/src/test/java/feign/BridgeMethodTest.java b/core/src/test/java/feign/BridgeMethodTest.java new file mode 100644 index 000000000..11846d6c9 --- /dev/null +++ b/core/src/test/java/feign/BridgeMethodTest.java @@ -0,0 +1,71 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign; + +import static feign.assertj.FeignAssertions.assertThat; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class BridgeMethodTest { + + interface CrudApi { + @RequestLine("GET /items/{id}") + String get(@Param("id") T id); + } + + interface UserApi extends CrudApi { + @Override + @RequestLine("GET /users/{id}") + String get(@Param("id") String id); + } + + @Test + void contractSkipsBridgeMethodsFromGenericOverride() { + List metadata = + new Contract.Default().parseAndValidateMetadata(UserApi.class); + + assertThat(metadata).hasSize(1); + assertThat(metadata.get(0).configKey()).isEqualTo("UserApi#get(String)"); + assertThat(metadata.get(0).template()).hasMethod("GET").hasUrl("/users/{id}"); + } + + @Test + void callsThroughGenericSuperInterfaceUseBridgedHandler() { + AtomicReference captured = new AtomicReference<>(); + + CrudApi api = + (CrudApi) + Feign.builder() + .client( + (request, options) -> { + captured.set(request); + return Response.builder() + .status(200) + .reason("OK") + .request(request) + .headers(Collections.emptyMap()) + .body("ok", Util.UTF_8) + .build(); + }) + .target(UserApi.class, "http://localhost:1"); + + assertThat(api.get("1")).isEqualTo("ok"); + assertThat(captured.get().url()).isEqualTo("http://localhost:1/users/1"); + } +} From 846f37892365b9fb3c78d4edb24745e3522c5e39 Mon Sep 17 00:00:00 2001 From: Abdullah Date: Wed, 12 Aug 2026 14:34:09 +0000 Subject: [PATCH 18/45] Format BridgeMethodTest for git-code-format --- core/src/test/java/feign/BridgeMethodTest.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/test/java/feign/BridgeMethodTest.java b/core/src/test/java/feign/BridgeMethodTest.java index 11846d6c9..c7f994d93 100644 --- a/core/src/test/java/feign/BridgeMethodTest.java +++ b/core/src/test/java/feign/BridgeMethodTest.java @@ -37,8 +37,7 @@ interface UserApi extends CrudApi { @Test void contractSkipsBridgeMethodsFromGenericOverride() { - List metadata = - new Contract.Default().parseAndValidateMetadata(UserApi.class); + List metadata = new Contract.Default().parseAndValidateMetadata(UserApi.class); assertThat(metadata).hasSize(1); assertThat(metadata.get(0).configKey()).isEqualTo("UserApi#get(String)"); From fcac4e7ce61e78474e832df785880bcdd1717893 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:12:50 +0000 Subject: [PATCH 19/45] build(deps): Bump org.openrewrite.recipe:rewrite-testing-frameworks Bumps [org.openrewrite.recipe:rewrite-testing-frameworks](https://github.com/openrewrite/rewrite-testing-frameworks) from 3.43.0 to 3.44.0. - [Release notes](https://github.com/openrewrite/rewrite-testing-frameworks/releases) - [Commits](https://github.com/openrewrite/rewrite-testing-frameworks/compare/v3.43.0...v3.44.0) --- updated-dependencies: - dependency-name: org.openrewrite.recipe:rewrite-testing-frameworks dependency-version: 3.44.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c4f4ee878..df6421280 100644 --- a/pom.xml +++ b/pom.xml @@ -207,7 +207,7 @@ 4.0.0 1.8.0 6.45.0 - 3.43.0 + 3.44.0 3.41.0 0.26.1 1.0 From 982fc3319eb60781d04d53641b239674191f15e5 Mon Sep 17 00:00:00 2001 From: Alhuda Khan Date: Thu, 13 Aug 2026 14:35:54 +0530 Subject: [PATCH 20/45] match Content-Length header case-insensitively in DefaultClient --- core/src/main/java/feign/DefaultClient.java | 2 +- .../java/feign/client/DefaultClientTest.java | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/feign/DefaultClient.java b/core/src/main/java/feign/DefaultClient.java index 54af6e6cd..0da34b1e2 100644 --- a/core/src/main/java/feign/DefaultClient.java +++ b/core/src/main/java/feign/DefaultClient.java @@ -172,7 +172,7 @@ HttpURLConnection convertAndSend(Request request, Options options) throws IOExce hasAcceptHeader = true; } for (String value : request.headers().get(field)) { - if (field.equals(CONTENT_LENGTH)) { + if (field.equalsIgnoreCase(CONTENT_LENGTH)) { if (!gzipEncodedRequest && !deflateEncodedRequest) { contentLength = Integer.valueOf(value); } diff --git a/core/src/test/java/feign/client/DefaultClientTest.java b/core/src/test/java/feign/client/DefaultClientTest.java index 07c4cec55..267370cad 100644 --- a/core/src/test/java/feign/client/DefaultClientTest.java +++ b/core/src/test/java/feign/client/DefaultClientTest.java @@ -41,6 +41,7 @@ import java.util.Map; import java.util.zip.GZIPOutputStream; import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.RecordedRequest; import okhttp3.mockwebserver.SocketPolicy; import okio.Buffer; import org.junit.jupiter.api.Test; @@ -149,6 +150,51 @@ public void noRequestBodyForPostWithAllowRestrictedHeaders() throws Exception { .hasHeaders(entry("Content-Length", Collections.singletonList("0"))); } + @Test + void lowerCaseContentLengthHeaderIsUsedForFixedLengthStreamingMode() throws Exception { + server.enqueue(new MockResponse()); + byte[] body = "hello".getBytes(StandardCharsets.UTF_8); + Map> headers = new LinkedHashMap<>(); + headers.put("content-length", Collections.singletonList(String.valueOf(body.length))); + Request request = + Request.create( + HttpMethod.POST, + "http://localhost:" + server.getPort() + "/", + headers, + body, + StandardCharsets.UTF_8, + null); + + // the two-arg constructor disables request buffering, so a recognised Content-Length selects + // fixed-length streaming mode and the JDK emits the header itself, exactly once + new DefaultClient(null, null).execute(request, new Request.Options()); + + RecordedRequest recordedRequest = server.takeRequest(); + assertThat(recordedRequest.getHeaders().values("Content-Length")) + .containsExactly(String.valueOf(body.length)); + assertThat(recordedRequest.getHeader("Transfer-Encoding")).isNull(); + } + + @Test + @EnabledIfSystemProperty(named = "sun.net.http.allowRestrictedHeaders", matches = "true") + public void contentLengthHeaderIsNotDuplicatedForBodylessRequest() throws Exception { + server.enqueue(new MockResponse()); + Map> headers = new LinkedHashMap<>(); + headers.put("content-length", Collections.singletonList("0")); + Request request = + Request.create( + HttpMethod.POST, + "http://localhost:" + server.getPort() + "/", + headers, + null, + StandardCharsets.UTF_8, + null); + + new DefaultClient(null, null).execute(request, new Request.Options()); + + assertThat(server.takeRequest().getHeaders().values("Content-Length")).containsExactly("0"); + } + @Test void emptyBodyDoesNotConvertGetToPost() throws Exception { server.enqueue(new MockResponse().setBody("foo")); From 20ef5f67573a9cc6eedfdc16b600b58a017838ed Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:44:41 +0700 Subject: [PATCH 21/45] Apply JAXB factory properties when creating unmarshallers `JAXBContextFactory.withProperty` was only applied to Marshallers. Unmarshallers now receive the same properties; marshaller-only keys are skipped so existing `withMarshaller*` plus decode setups keep working. Fixes #3056 Signed-off-by: arimu1 <19286898+arimu1@users.noreply.github.com> --- CHANGELOG.md | 2 + .../java/feign/jaxb/JAXBContextFactory.java | 14 ++++- .../test/java/feign/jaxb/JAXBCodecTest.java | 52 +++++++++++++++++++ .../feign/jaxb/JAXBContextFactoryTest.java | 30 +++++++++++ .../java/feign/jaxb/JAXBContextFactory.java | 14 ++++- .../test/java/feign/jaxb/JAXBCodecTest.java | 50 ++++++++++++++++++ .../feign/jaxb/JAXBContextFactoryTest.java | 30 +++++++++++ 7 files changed, 190 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 811b0eeca..513e923ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ### Version 13.14 +* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only + Marshallers. Marshaller-only properties are skipped on unmarshal (#3056). * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and incorporates a body hash into the cache key to reduce cross-body collisions. diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java index 07918bc6c..36692a6f9 100644 --- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java @@ -66,6 +66,7 @@ private JAXBContextFactory( /** Creates a new {@link jakarta.xml.bind.Unmarshaller} that handles the supplied class. */ public Unmarshaller createUnmarshaller(Class clazz) throws JAXBException { Unmarshaller unmarshaller = getContext(clazz).createUnmarshaller(); + setUnmarshallerProperties(unmarshaller); if (unmarshallerEventHandler != null) { unmarshaller.setEventHandler(unmarshallerEventHandler); } @@ -90,6 +91,16 @@ private void setMarshallerProperties(Marshaller marshaller) throws PropertyExcep } } + private void setUnmarshallerProperties(Unmarshaller unmarshaller) { + for (Entry en : properties.entrySet()) { + try { + unmarshaller.setProperty(en.getKey(), en.getValue()); + } catch (PropertyException ignored) { + // The same map holds marshaller-only properties (for example JAXB_FORMATTED_OUTPUT). + } + } + } + private JAXBContext getContext(Class clazz) throws JAXBException { JAXBContextCacheKey cacheKey = jaxbContextInstantationMode.getJAXBContextCacheKey(clazz); JAXBContext jaxbContext = this.jaxbContexts.get(cacheKey); @@ -164,7 +175,8 @@ public Builder withMarshallerFragment(Boolean value) { } /** - * Sets the given property of any Marshaller created by this factory. + * Sets the given property of any Marshaller or Unmarshaller created by this factory. + * Marshaller-only properties are ignored when creating an Unmarshaller. * *

Example :
*
diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java index d79d3c472..1924c28d0 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -30,6 +30,7 @@ import feign.codec.Encoder; import jakarta.xml.bind.MarshalException; import jakarta.xml.bind.UnmarshalException; +import jakarta.xml.bind.ValidationEventHandler; import jakarta.xml.bind.annotation.XmlAccessType; import jakarta.xml.bind.annotation.XmlAccessorType; import jakarta.xml.bind.annotation.XmlElement; @@ -40,10 +41,13 @@ import java.util.Collections; import java.util.Map; import java.util.Objects; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; import javax.xml.XMLConstants; import javax.xml.transform.stream.StreamSource; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; +import org.glassfish.jaxb.runtime.IDResolver; import org.junit.jupiter.api.Test; @SuppressWarnings("deprecation") @@ -210,6 +214,54 @@ void decodesXml() throws Exception { assertThat(decoder.decode(response, MockObject.class)).isEqualTo(mock); } + @Test + void decodesXmlUsingFactoryProperty() throws Exception { + MockObject mock = new MockObject(); + mock.value = "Test"; + + String mockXml = + """ + \ + Test\ + """; + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(mockXml, UTF_8) + .build(); + + AtomicBoolean started = new AtomicBoolean(); + IDResolver resolver = + new IDResolver() { + @Override + public void startDocument(ValidationEventHandler eventHandler) { + started.set(true); + } + + @Override + public void bind(String id, Object obj) {} + + @Override + public Callable resolve(String id, Class targetType) { + return () -> null; + } + }; + + JAXBContextFactory factory = + new JAXBContextFactory.Builder() + .withMarshallerFormattedOutput(true) + .withProperty(IDResolver.class.getName(), resolver) + .build(); + + assertThat(new JAXBDecoder(factory).decode(response, MockObject.class)).isEqualTo(mock); + assertThat(started).isTrue(); + } + @Test void doesntDecodeParameterizedTypes() throws Exception { diff --git a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java index e3301ec76..53ceeb61b 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -26,9 +26,11 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; import javax.xml.XMLConstants; import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; +import org.glassfish.jaxb.runtime.IDResolver; import org.junit.jupiter.api.Test; class JAXBContextFactoryTest { @@ -94,6 +96,34 @@ void buildsMarshallerWithSchema() throws Exception { assertThat(marshaller.getSchema()).isSameAs(schema); } + @Test + void buildsUnmarshallerWithProperty() throws Exception { + IDResolver resolver = + new IDResolver() { + @Override + public void bind(String id, Object obj) {} + + @Override + public Callable resolve(String id, Class targetType) { + return () -> null; + } + }; + JAXBContextFactory factory = + new JAXBContextFactory.Builder().withProperty(IDResolver.class.getName(), resolver).build(); + + Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class); + assertThat(unmarshaller.getProperty(IDResolver.class.getName())).isSameAs(resolver); + } + + @Test + void buildsUnmarshallerWhenFactoryHasMarshallerProperties() throws Exception { + JAXBContextFactory factory = + new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build(); + + Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class); + assertThat(unmarshaller).isNotNull(); + } + @Test void buildsUnmarshallerWithSchema() throws Exception { Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(); diff --git a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java index 3f057d8c0..dcd7f6c24 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBContextFactory.java @@ -66,6 +66,7 @@ private JAXBContextFactory( /** Creates a new {@link javax.xml.bind.Unmarshaller} that handles the supplied class. */ public Unmarshaller createUnmarshaller(Class clazz) throws JAXBException { Unmarshaller unmarshaller = getContext(clazz).createUnmarshaller(); + setUnmarshallerProperties(unmarshaller); if (unmarshallerEventHandler != null) { unmarshaller.setEventHandler(unmarshallerEventHandler); } @@ -90,6 +91,16 @@ private void setMarshallerProperties(Marshaller marshaller) throws PropertyExcep } } + private void setUnmarshallerProperties(Unmarshaller unmarshaller) { + for (Entry en : properties.entrySet()) { + try { + unmarshaller.setProperty(en.getKey(), en.getValue()); + } catch (PropertyException ignored) { + // The same map holds marshaller-only properties (for example JAXB_FORMATTED_OUTPUT). + } + } + } + private JAXBContext getContext(Class clazz) throws JAXBException { JAXBContextCacheKey cacheKey = jaxbContextInstantationMode.getJAXBContextCacheKey(clazz); JAXBContext jaxbContext = this.jaxbContexts.get(cacheKey); @@ -164,7 +175,8 @@ public Builder withMarshallerFragment(Boolean value) { } /** - * Sets the given property of any Marshaller created by this factory. + * Sets the given property of any Marshaller or Unmarshaller created by this factory. + * Marshaller-only properties are ignored when creating an Unmarshaller. * *

Example :
*
diff --git a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java index 464d857b8..1b2bd51ba 100644 --- a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import com.sun.xml.bind.IDResolver; import feign.Request; import feign.Request.HttpMethod; import feign.RequestTemplate; @@ -34,6 +35,7 @@ import java.util.Collections; import java.util.Map; import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; import javax.xml.XMLConstants; import javax.xml.bind.MarshalException; import javax.xml.bind.UnmarshalException; @@ -210,6 +212,54 @@ void decodesXml() throws Exception { assertThat(decoder.decode(response, MockObject.class)).isEqualTo(mock); } + @Test + void decodesXmlUsingFactoryProperty() throws Exception { + MockObject mock = new MockObject(); + mock.value = "Test"; + + String mockXml = + """ + \ + Test\ + """; + + Response response = + Response.builder() + .status(200) + .reason("OK") + .request( + Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(Collections.emptyMap()) + .body(mockXml, UTF_8) + .build(); + + AtomicBoolean started = new AtomicBoolean(); + IDResolver resolver = + new IDResolver() { + @Override + public void startDocument(javax.xml.bind.ValidationEventHandler eventHandler) { + started.set(true); + } + + @Override + public void bind(String id, Object obj) {} + + @Override + public java.util.concurrent.Callable resolve(String id, Class targetType) { + return () -> null; + } + }; + + JAXBContextFactory factory = + new JAXBContextFactory.Builder() + .withMarshallerFormattedOutput(true) + .withProperty(IDResolver.class.getName(), resolver) + .build(); + + assertThat(new JAXBDecoder(factory).decode(response, MockObject.class)).isEqualTo(mock); + assertThat(started).isTrue(); + } + @Test void doesntDecodeParameterizedTypes() throws Exception { diff --git a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java index baee12e45..f645f38e6 100644 --- a/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java +++ b/jaxb/src/test/java/feign/jaxb/JAXBContextFactoryTest.java @@ -17,12 +17,14 @@ import static org.assertj.core.api.Assertions.assertThat; +import com.sun.xml.bind.IDResolver; import feign.jaxb.mock.onepackage.AnotherMockedJAXBObject; import feign.jaxb.mock.onepackage.MockedJAXBObject; import java.lang.reflect.Field; import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.Callable; import javax.xml.XMLConstants; import javax.xml.bind.Marshaller; import javax.xml.bind.Unmarshaller; @@ -94,6 +96,34 @@ void buildsMarshallerWithSchema() throws Exception { assertThat(marshaller.getSchema()).isSameAs(schema); } + @Test + void buildsUnmarshallerWithProperty() throws Exception { + IDResolver resolver = + new IDResolver() { + @Override + public void bind(String id, Object obj) {} + + @Override + public Callable resolve(String id, Class targetType) { + return () -> null; + } + }; + JAXBContextFactory factory = + new JAXBContextFactory.Builder().withProperty(IDResolver.class.getName(), resolver).build(); + + Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class); + assertThat(unmarshaller.getProperty(IDResolver.class.getName())).isSameAs(resolver); + } + + @Test + void buildsUnmarshallerWhenFactoryHasMarshallerProperties() throws Exception { + JAXBContextFactory factory = + new JAXBContextFactory.Builder().withMarshallerFormattedOutput(true).build(); + + Unmarshaller unmarshaller = factory.createUnmarshaller(Object.class); + assertThat(unmarshaller).isNotNull(); + } + @Test void buildsUnmarshallerWithSchema() throws Exception { Schema schema = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI).newSchema(); From a35e41bd16d840fe0e99d7231a5abd554691509a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:16:23 +0000 Subject: [PATCH 22/45] build(deps-dev): Bump org.openrewrite.maven:rewrite-maven-plugin Bumps [org.openrewrite.maven:rewrite-maven-plugin](https://github.com/openrewrite/rewrite-maven-plugin) from 6.45.0 to 6.46.1. - [Release notes](https://github.com/openrewrite/rewrite-maven-plugin/releases) - [Commits](https://github.com/openrewrite/rewrite-maven-plugin/compare/v6.45.0...v6.46.1) --- updated-dependencies: - dependency-name: org.openrewrite.maven:rewrite-maven-plugin dependency-version: 6.46.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index df6421280..642e2b895 100644 --- a/pom.xml +++ b/pom.xml @@ -206,7 +206,7 @@ 1.2.8 4.0.0 1.8.0 - 6.45.0 + 6.46.1 3.44.0 3.41.0 0.26.1 From 67df9e3a584d1551b2f839ee28a1a85093bff519 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Aug 2026 05:16:41 +0000 Subject: [PATCH 23/45] build(deps): Bump org.openrewrite.recipe:rewrite-migrate-java Bumps [org.openrewrite.recipe:rewrite-migrate-java](https://github.com/openrewrite/rewrite-migrate-java) from 3.41.0 to 3.42.0. - [Release notes](https://github.com/openrewrite/rewrite-migrate-java/releases) - [Commits](https://github.com/openrewrite/rewrite-migrate-java/compare/v3.41.0...v3.42.0) --- updated-dependencies: - dependency-name: org.openrewrite.recipe:rewrite-migrate-java dependency-version: 3.42.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index df6421280..842e71eff 100644 --- a/pom.xml +++ b/pom.xml @@ -208,7 +208,7 @@ 1.8.0 6.45.0 3.44.0 - 3.41.0 + 3.42.0 0.26.1 1.0 From a090a5a84382bc1f36984b8718a8ce9c91686889 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:12:53 +0000 Subject: [PATCH 24/45] build(deps): Bump org.json:json from 20260719 to 20260814 Bumps [org.json:json](https://github.com/douglascrockford/JSON-java) from 20260719 to 20260814. - [Release notes](https://github.com/douglascrockford/JSON-java/releases) - [Changelog](https://github.com/stleary/JSON-java/blob/master/docs/RELEASES.md) - [Commits](https://github.com/douglascrockford/JSON-java/compare/20260719...20260814) --- updated-dependencies: - dependency-name: org.json:json dependency-version: '20260814' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8d05ccd1c..2cec4a788 100644 --- a/pom.xml +++ b/pom.xml @@ -170,7 +170,7 @@ 2.14.0 1.15.2 2.0.18 - 20260719 + 20260814 4.1.0 6.1.3 From 5d532e97d69086906b1dc81d98b63108c45021f1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:14:09 +0000 Subject: [PATCH 25/45] build(deps): Bump tools.jackson:jackson-bom from 3.2.1 to 3.2.2 Bumps [tools.jackson:jackson-bom](https://github.com/FasterXML/jackson-bom) from 3.2.1 to 3.2.2. - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-3.2.1...jackson-bom-3.2.2) --- updated-dependencies: - dependency-name: tools.jackson:jackson-bom dependency-version: 3.2.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 8d05ccd1c..b222c638e 100644 --- a/pom.xml +++ b/pom.xml @@ -175,7 +175,7 @@ 6.1.3 2.22.1 - 3.2.1 + 3.2.2 3.27.7 5.23.0 2.0.64.android8 From e1143548ea6c1b2d288be246a0c9d59b902ac8a1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:12:26 +0000 Subject: [PATCH 26/45] build(deps): Bump com.google.guava:guava from 33.6.0-jre to 33.7.1-jre Bumps [com.google.guava:guava](https://github.com/google/guava) from 33.6.0-jre to 33.7.1-jre. - [Release notes](https://github.com/google/guava/releases) - [Commits](https://github.com/google/guava/commits) --- updated-dependencies: - dependency-name: com.google.guava:guava dependency-version: 33.7.1-jre dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 2a97d1751..e3497f91f 100644 --- a/pom.xml +++ b/pom.xml @@ -165,7 +165,7 @@ ${main.java.version} 5.4.0 - 33.6.0-jre + 33.7.1-jre 2.2.0 2.14.0 1.15.2 From 51d1692dd8d3817545ab3b4fecd3ba8f8848a5b4 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 08:13:03 -0300 Subject: [PATCH 27/45] Decode every root field of a GraphQL operation Signed-off-by: Marvin Froeder --- graphql-apt/README.md | 23 +++++ .../graphql/apt/GraphqlSchemaProcessor.java | 75 ++++++++++----- .../apt/GraphqlSchemaProcessorTest.java | 96 +++++++++++++++++++ graphql/README.md | 34 +++++++ .../java/feign/graphql/GraphqlDecoder.java | 12 ++- .../java/feign/graphql/GraphqlClientTest.java | 33 +++++++ .../feign/graphql/GraphqlDecoderTest.java | 71 ++++++++++++++ 7 files changed, 316 insertions(+), 28 deletions(-) diff --git a/graphql-apt/README.md b/graphql-apt/README.md index cc6935233..dc0b27cb4 100644 --- a/graphql-apt/README.md +++ b/graphql-apt/README.md @@ -56,6 +56,29 @@ public record CharByRegion(String id, Location location) { } ``` +### Multiple root fields + +An operation selecting several root fields gets a record with one component per field, mirroring the operation's own selection set rather than a single field's type: + +```graphql +query authorPage($authorId: ID!) { + books(authorId: $authorId) { id title } + reviews(authorId: $authorId) { id rating } +} +``` + +```java +public record AuthorPage(Optional> books, Optional> reviews) { + + public record Books(String id, String title) {} + + public record Reviews(String id, Integer rating) {} + +} +``` + +With a single root field the record still mirrors that field's type, so `{ character(id: "1") { id name } }` keeps generating `record CharacterResult(String id, String name)`. + ### Conflicting return type error If two queries use the same return type name but select different fields, the processor reports compilation errors on both methods showing which fields each selects: diff --git a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java index 22ed4e4cd..c69b19a8d 100644 --- a/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java +++ b/graphql-apt/src/main/java/feign/graphql/apt/GraphqlSchemaProcessor.java @@ -247,22 +247,7 @@ private void processMethod( var returnTypeName = getSimpleTypeName(method.getReturnType()); if (returnTypeName != null && !isExistingExternalType(method.getReturnType(), targetPackage)) { - var rootType = getRootType(operation, registry); - if (rootType != null) { - var rootField = findRootField(operation.getSelectionSet()); - if (rootField != null && rootField.getSelectionSet() != null) { - var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName()); - if (rootFieldDef != null) { - var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType()); - var fieldObjectType = - registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null); - if (fieldObjectType != null) { - generator.generateResultType( - returnTypeName, rootField.getSelectionSet(), fieldObjectType, method); - } - } - } - } + generateReturnType(returnTypeName, operation, registry, generator, method); } var params = method.getParameters(); @@ -294,16 +279,58 @@ private OperationDefinition findOperation(Document document) { return null; } - private Field findRootField(SelectionSet selectionSet) { - if (selectionSet == null) { - return null; + /** + * A single root field is the operation result itself, so the record mirrors that field's type. + * Several root fields are all part of the result — the decoder binds the whole {@code data} map — + * so the record mirrors the operation's own selection set, one component per root field. + */ + private void generateReturnType( + String returnTypeName, + OperationDefinition operation, + TypeDefinitionRegistry registry, + TypeGenerator generator, + ExecutableElement method) { + var rootType = getRootType(operation, registry); + if (rootType == null) { + return; } - for (var selection : selectionSet.getSelections()) { - if (selection instanceof Field field) { - return field; - } + + var rootFields = rootFields(operation.getSelectionSet()); + if (rootFields.size() > 1) { + generator.generateResultType(returnTypeName, operation.getSelectionSet(), rootType, method); + return; } - return null; + + if (rootFields.isEmpty()) { + return; + } + + var rootField = rootFields.get(0); + if (rootField.getSelectionSet() == null) { + return; + } + + var rootFieldDef = GraphqlTypeMapper.findFieldDefinition(rootType, rootField.getName()); + if (rootFieldDef == null) { + return; + } + + var fieldTypeName = GraphqlTypeMapper.unwrapTypeName(rootFieldDef.getType()); + var fieldObjectType = registry.getType(fieldTypeName, ObjectTypeDefinition.class).orElse(null); + if (fieldObjectType != null) { + generator.generateResultType( + returnTypeName, rootField.getSelectionSet(), fieldObjectType, method); + } + } + + private List rootFields(SelectionSet selectionSet) { + if (selectionSet == null) { + return List.of(); + } + return selectionSet.getSelections().stream() + .filter(Field.class::isInstance) + .map(Field.class::cast) + .toList(); } private ObjectTypeDefinition getRootType( diff --git a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java index e4488eed1..3bd7b9bc1 100644 --- a/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java +++ b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java @@ -635,6 +635,102 @@ interface InnerApi { "public record Specs(Optional lengthMeters, Optional classification) {}"); } + @Test + void multipleRootFieldsGenerateOneComponentPerField() { + var source = + JavaFileObjects.forSourceString( + "test.MultiRootApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MultiRootApi { + @GraphqlQuery(\""" + query overview($id: ID!) { + character(id: $id) { id name } + starship(id: $id) { id name } + }\""") + Overview overview(String id); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = + assertThat(compilation).generatedSourceFile("test.Overview").contentsAsUtf8String(); + + contents.contains( + "public record Overview(Optional character, Optional starship) {"); + contents.contains("public record Character(String id, String name) {}"); + contents.contains("public record Starship(String id, String name) {}"); + } + + @Test + void multipleRootFieldsKeepListAndScalarShapes() { + var source = + JavaFileObjects.forSourceString( + "test.MultiRootListApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface MultiRootListApi { + @GraphqlQuery(\""" + query page($id: ID!) { + characters { id name } + starship(id: $id) { id name } + }\""") + Page page(String id); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + var contents = assertThat(compilation).generatedSourceFile("test.Page").contentsAsUtf8String(); + + contents.contains( + "public record Page(Optional> characters, Optional starship) {"); + contents.contains("public record Characters(String id, String name) {}"); + } + + @Test + void singleRootFieldStillMirrorsThatFieldType() { + var source = + JavaFileObjects.forSourceString( + "test.SingleRootApi", + """ + package test; + + import feign.graphql.GraphqlSchema; + import feign.graphql.GraphqlQuery; + + @GraphqlSchema("test-schema.graphql") + interface SingleRootApi { + @GraphqlQuery("query one($id: ID!) { character(id: $id) { id name } }") + One one(String id); + } + """); + + var compilation = javac().withProcessors(new GraphqlSchemaProcessor()).compile(source); + + assertThat(compilation).succeeded(); + + assertThat(compilation) + .generatedSourceFile("test.One") + .contentsAsUtf8String() + .contains("public record One(String id, String name) {}"); + } + @Test void differentQueriesDifferentNestedFields() { var source = diff --git a/graphql/README.md b/graphql/README.md index 11715fbc5..6acfcf601 100644 --- a/graphql/README.md +++ b/graphql/README.md @@ -215,6 +215,40 @@ The processor maps `DateTime` fields to `java.time.Instant` in the generated rec public record Event(String id, String name, Instant startTime) {} ``` +## Multiple Root Fields + +A single operation can select more than one root field, and all of them are decoded — one round trip instead of one call per field: + +```java +@GraphqlQuery(""" + query authorPage($authorId: ID!) { + books(authorId: $authorId) { id title } + reviews(authorId: $authorId) { id rating } + } + """) +AuthorPage authorPage(String authorId); +``` + +The processor generates a record with one component per root field, each with its own inner record: + +```java +public record AuthorPage(Optional> books, Optional> reviews) { + + public record Books(String id, String title) {} + + public record Reviews(String id, Integer rating) {} + +} +``` + +When the response carries several root fields the whole `data` map binds to the return type, so every field lands on its matching component: + +```json +{"data": {"books": [...], "reviews": [...]}} +``` + +Operations with a single root field are unaffected: that field is still unwrapped and decoded into the return type directly. + ## Single Result from Array Queries When a GraphQL query returns an array type (e.g. `[User!]`) but the Java method declares a single return type, the decoder automatically unwraps the first element: diff --git a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java index e28a8d691..54b97e0db 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -113,13 +113,17 @@ private Object unwrap(Map root, Type type, int status, Request r } var dataMap = (Map) data; - var fieldNames = dataMap.keySet().iterator(); - if (!fieldNames.hasNext()) { + if (dataMap.isEmpty()) { return Util.emptyValueOf(type); } - var firstField = fieldNames.next(); - var operationData = dataMap.get(firstField); + // A single root field is the operation result itself; several root fields are its components, + // so the whole data map binds to the return type and no field gets dropped. + if (dataMap.size() > 1) { + return jsonDecoder.convert(dataMap, type); + } + + var operationData = dataMap.values().iterator().next(); if (operationData == null) { return Util.emptyValueOf(type); } diff --git a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java index 2de395de0..60a83d51e 100644 --- a/graphql/src/test/java/feign/graphql/GraphqlClientTest.java +++ b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java @@ -55,6 +55,12 @@ public static class CreateUserResult { public String name; } + public record Book(String id, String title) {} + + public record Review(String id, Integer rating) {} + + public record AuthorPage(List books, List reviews) {} + @Headers("Content-Type: application/json") interface TestApi { @@ -78,6 +84,12 @@ interface TestApi { @GraphqlQuery("query topUser($limit: Int!) {" + " topUsers(limit: $limit) { id name email } }") User topUser(int limit); + + @GraphqlQuery( + "query authorPage($authorId: ID!) {" + + " books(authorId: $authorId) { id title }" + + " reviews(authorId: $authorId) { id rating } }") + AuthorPage authorPage(String authorId); } @BeforeEach @@ -215,6 +227,27 @@ void optionalReturnTypeEmptyWhenNull() throws Exception { assertThat(user).isEmpty(); } + @Test + void multipleRootFieldsDecodedIntoSingleResult() throws Exception { + server.enqueue( + new MockResponse() + .setBody( + "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]," + + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}") + .addHeader("Content-Type", "application/json")); + + var page = buildClient().authorPage("42"); + + assertThat(page.books()).hasSize(1); + assertThat(page.books().getFirst().title()).isEqualTo("Dune"); + assertThat(page.reviews()).hasSize(1); + assertThat(page.reviews().getFirst().rating()).isEqualTo(5); + + var recorded = server.takeRequest(); + var body = mapper.readTree(recorded.getBody().readUtf8()); + assertThat(body.get("variables").get("authorId").asText()).isEqualTo("42"); + } + @Test void authHeaderPassedThrough() throws Exception { server.enqueue( diff --git a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java index b796a6861..412d9d50f 100644 --- a/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java +++ b/graphql/src/test/java/feign/graphql/GraphqlDecoderTest.java @@ -53,6 +53,14 @@ public record UserWithAddress(String id, Optional

address) {} public record DeeplyNested(String value, Optional nested) {} + public record Book(String id, String title) {} + + public record Review(String id, Integer rating) {} + + public record AuthorPage(List books, List reviews) {} + + public record MixedPage(User getUser, List books) {} + @Test void decodesDataField() throws Exception { var json = "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}}}"; @@ -320,6 +328,69 @@ void returnsEmptyListForNullOperationDataWithListType() throws Exception { assertThat(result).isEmpty(); } + @Test + void decodesMultipleRootFieldsIntoRecord() throws Exception { + var json = + "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]," + + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}"; + var response = buildResponse(json); + + var page = (AuthorPage) decoder.decode(response, AuthorPage.class); + + assertThat(page.books()).hasSize(1); + assertThat(page.books().getFirst().title()).isEqualTo("Dune"); + assertThat(page.reviews()).hasSize(1); + assertThat(page.reviews().getFirst().rating()).isEqualTo(5); + } + + @Test + void decodesMultipleRootFieldsOfDifferentShapes() throws Exception { + var json = + "{\"data\":{\"getUser\":{\"id\":\"1\",\"name\":\"Alice\"}," + + "\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]}}"; + var response = buildResponse(json); + + var page = (MixedPage) decoder.decode(response, MixedPage.class); + + assertThat(page.getUser().name).isEqualTo("Alice"); + assertThat(page.books()).hasSize(1); + } + + @Test + void keepsNullRootFieldWhenDecodingMultipleRootFields() throws Exception { + var json = "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}],\"reviews\":null}}"; + var response = buildResponse(json); + + var page = (AuthorPage) decoder.decode(response, AuthorPage.class); + + assertThat(page.books()).hasSize(1); + assertThat(page.reviews()).isNull(); + } + + @Test + void decodesMultipleRootFieldsIntoOptional() throws Exception { + var json = + "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]," + + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}"; + var response = buildResponse(json); + + @SuppressWarnings("unchecked") + var page = (Optional) decoder.decode(response, optionalOf(AuthorPage.class)); + + assertThat(page).isPresent(); + assertThat(page.get().reviews()).hasSize(1); + } + + @Test + void throwsGraphqlErrorExceptionOnErrorsWithMultipleRootFields() { + var json = "{\"errors\":[{\"message\":\"Boom\"}],\"data\":{\"books\":null,\"reviews\":null}}"; + var response = buildResponse(json); + + assertThatThrownBy(() -> decoder.decode(response, AuthorPage.class)) + .isInstanceOf(GraphqlErrorException.class) + .hasMessageContaining("Boom"); + } + private Response buildResponse(String body) { return Response.builder() .status(200) From a291578ff630d46641241b521fb7e30d8f55873b Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 19 Aug 2026 09:27:19 -0300 Subject: [PATCH 28/45] Add Util helpers for detecting JSON and XML content types Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder --- core/src/main/java/feign/Util.java | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index 91cb7e5a1..b91144242 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -51,6 +51,7 @@ import java.util.TreeMap; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Stream; /** Utilities, typically copied in from guava, so as to avoid dependency conflicts. */ @@ -62,6 +63,9 @@ public class Util { /** The HTTP Content-Encoding header field name. */ public static final String CONTENT_ENCODING = "Content-Encoding"; + /** The HTTP Content-Type header field name. */ + public static final String CONTENT_TYPE = "Content-Type"; + /** The HTTP Accept-Encoding header field name. */ public static final String ACCEPT_ENCODING = "Accept-Encoding"; @@ -83,6 +87,15 @@ public class Util { private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + // matches application/json, text/json, application/vnd.github+json, + // application/json;charset=utf-8 + private static final Pattern JSON_CONTENT_TYPE = + Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?json.*"); + + // matches application/xml, text/xml, application/soap+xml, application/xml;charset=utf-8 + private static final Pattern XML_CONTENT_TYPE = + Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?xml.*"); + /** Type literal for {@code Map}. */ public static final Type MAP_STRING_WILDCARD = new Types.ParameterizedTypeImpl( @@ -371,4 +384,40 @@ public static String getThreadIdentifier() { + "_" + currentThread.getId(); } + + /** + * Checks whether the {@code Content-Type} header of the given template denotes JSON. + * + *

Matches {@code application/json} as well as suffixed types such as {@code + * application/vnd.github+json}. The header name is matched case-insensitively. + * + * @param template the request template to check + * @return {@code true} if the content type is JSON, {@code false} otherwise + */ + public static boolean isJsonContentType(RequestTemplate template) { + return hasContentTypeMatching(template, JSON_CONTENT_TYPE); + } + + /** + * Checks whether the {@code Content-Type} header of the given template denotes XML. + * + *

Matches {@code application/xml} and {@code text/xml} as well as suffixed types such as + * {@code application/soap+xml}. The header name is matched case-insensitively. + * + * @param template the request template to check + * @return {@code true} if the content type is XML, {@code false} otherwise + */ + public static boolean isXmlContentType(RequestTemplate template) { + return hasContentTypeMatching(template, XML_CONTENT_TYPE); + } + + private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) { + return template.headers().entrySet().stream() + .filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .anyMatch( + contentType -> contentType != null && pattern.matcher(contentType.trim()).matches()); + } } From a8f11c23dff36e6e2805912b7a47c16ec105af96 Mon Sep 17 00:00:00 2001 From: kevin Date: Wed, 19 Aug 2026 09:27:25 -0300 Subject: [PATCH 29/45] Add PredicatedEncoder and EncoderPredicate for conditional encoding Co-authored-by: Yevhen Vasyliev Signed-off-by: Marvin Froeder --- .../java/feign/codec/EncoderPredicate.java | 44 ++++++ .../java/feign/codec/PredicatedEncoder.java | 93 ++++++++++++ .../feign/codec/PredicatedEncoderTest.java | 138 ++++++++++++++++++ 3 files changed, 275 insertions(+) create mode 100644 core/src/main/java/feign/codec/EncoderPredicate.java create mode 100644 core/src/main/java/feign/codec/PredicatedEncoder.java create mode 100644 core/src/test/java/feign/codec/PredicatedEncoderTest.java diff --git a/core/src/main/java/feign/codec/EncoderPredicate.java b/core/src/main/java/feign/codec/EncoderPredicate.java new file mode 100644 index 000000000..a00ceb263 --- /dev/null +++ b/core/src/main/java/feign/codec/EncoderPredicate.java @@ -0,0 +1,44 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.RequestTemplate; +import java.lang.reflect.Type; + +/** + * A predicate that decides whether a given request can be handled by an {@link Encoder}. + * + *

Predicates receive the same three arguments as {@link Encoder#encode(Object, Type, + * RequestTemplate)}, so they can discriminate on the body, on its declared type, or on anything + * already present in the template such as the {@code Content-Type} header. + * + * @see PredicatedEncoder + * @see MultiEncoder + */ +@FunctionalInterface +public interface EncoderPredicate { + + /** + * Tests whether the given request can be encoded. + * + * @param object what would be encoded as the request body + * @param bodyType the type the object would be encoded as. {@link Encoder#MAP_STRING_WILDCARD} + * indicates form encoding. + * @param template the request template that would be populated + * @return {@code true} if the request can be encoded, {@code false} otherwise + */ + boolean test(Object object, Type bodyType, RequestTemplate template); +} diff --git a/core/src/main/java/feign/codec/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java new file mode 100644 index 000000000..f70b96133 --- /dev/null +++ b/core/src/main/java/feign/codec/PredicatedEncoder.java @@ -0,0 +1,93 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.RequestTemplate; +import feign.Util; +import java.lang.reflect.Type; +import java.util.Objects; + +/** + * Pairs an {@link EncoderPredicate} with the {@link Encoder} it guards, so that a {@link + * MultiEncoder} can pick the right encoder per request. + * + *

Encoding through a {@code PredicatedEncoder} directly is allowed but strict: a request its + * predicate rejects raises {@link EncodeException} rather than silently doing nothing. Inside a + * {@link MultiEncoder} a rejected request simply moves on to the next candidate. + * + *

+ * Feign.builder()
+ *     .encoder(
+ *         new DefaultEncoder(),
+ *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
+ *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()))
+ * 
+ */ +public class PredicatedEncoder implements Encoder { + + private final EncoderPredicate predicate; + + private final Encoder delegate; + + public PredicatedEncoder(EncoderPredicate predicate, Encoder delegate) { + this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null"); + this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); + } + + /** Restricts the delegate to requests whose {@code Content-Type} header denotes JSON. */ + public static PredicatedEncoder forJsonContentType(Encoder delegate) { + return new PredicatedEncoder( + (object, bodyType, template) -> Util.isJsonContentType(template), delegate); + } + + /** Restricts the delegate to requests whose {@code Content-Type} header denotes XML. */ + public static PredicatedEncoder forXmlContentType(Encoder delegate) { + return new PredicatedEncoder( + (object, bodyType, template) -> Util.isXmlContentType(template), delegate); + } + + /** Restricts the delegate to requests carrying no body. */ + public static PredicatedEncoder forEmptyBody(Encoder delegate) { + return new PredicatedEncoder((object, bodyType, template) -> object == null, delegate); + } + + /** + * Whether the guarded encoder accepts this request. + * + * @param object what to encode as the request body + * @param bodyType the type the object should be encoded as + * @param template the request template to populate + * @return {@code true} if the delegate should handle this request + */ + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return predicate.test(object, bodyType, template); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + if (!canEncode(object, bodyType, template)) { + throw new EncodeException( + "Predicate of " + this + " rejected the request, so " + delegate + " was not invoked"); + } + delegate.encode(object, bodyType, template); + } + + @Override + public String toString() { + return "PredicatedEncoder{predicate=" + predicate + ", delegate=" + delegate + '}'; + } +} diff --git a/core/src/test/java/feign/codec/PredicatedEncoderTest.java b/core/src/test/java/feign/codec/PredicatedEncoderTest.java new file mode 100644 index 000000000..69a90b08d --- /dev/null +++ b/core/src/test/java/feign/codec/PredicatedEncoderTest.java @@ -0,0 +1,138 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.Request; +import feign.RequestTemplate; +import java.lang.reflect.Type; +import org.junit.jupiter.api.Test; + +class PredicatedEncoderTest { + + private static class RecordingEncoder implements Encoder { + boolean invoked; + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + invoked = true; + template.body(Request.Body.create("encoded")); + } + } + + private static RequestTemplate templateWithContentType(String contentType) { + RequestTemplate template = new RequestTemplate(); + if (contentType != null) { + template.header("Content-Type", contentType); + } + return template; + } + + @Test + void delegatesWhenPredicateAccepts() { + RecordingEncoder delegate = new RecordingEncoder(); + PredicatedEncoder encoder = new PredicatedEncoder((o, t, tpl) -> true, delegate); + + RequestTemplate template = templateWithContentType(null); + encoder.encode("body", String.class, template); + + assertThat(delegate.invoked).isTrue(); + assertThat(template.requestBody().asString()).isEqualTo("encoded"); + } + + @Test + void throwsAndSkipsDelegateWhenPredicateRejects() { + RecordingEncoder delegate = new RecordingEncoder(); + PredicatedEncoder encoder = new PredicatedEncoder((o, t, tpl) -> false, delegate); + + assertThatThrownBy(() -> encoder.encode("body", String.class, templateWithContentType(null))) + .isInstanceOf(EncodeException.class); + + assertThat(delegate.invoked).isFalse(); + } + + @Test + void canEncodeReflectsThePredicate() { + PredicatedEncoder encoder = + new PredicatedEncoder((o, t, tpl) -> "yes".equals(o), new RecordingEncoder()); + + assertThat(encoder.canEncode("yes", String.class, templateWithContentType(null))).isTrue(); + assertThat(encoder.canEncode("no", String.class, templateWithContentType(null))).isFalse(); + } + + @Test + void forJsonContentTypeMatchesJsonOnly() { + PredicatedEncoder encoder = PredicatedEncoder.forJsonContentType(new RecordingEncoder()); + + assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/json"))) + .isTrue(); + assertThat( + encoder.canEncode( + null, String.class, templateWithContentType("application/json;charset=utf-8"))) + .isTrue(); + assertThat( + encoder.canEncode( + null, String.class, templateWithContentType("application/vnd.github+json"))) + .isTrue(); + assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/xml"))) + .isFalse(); + assertThat(encoder.canEncode(null, String.class, templateWithContentType(null))).isFalse(); + } + + @Test + void forXmlContentTypeMatchesXmlOnly() { + PredicatedEncoder encoder = PredicatedEncoder.forXmlContentType(new RecordingEncoder()); + + assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/xml"))) + .isTrue(); + assertThat(encoder.canEncode(null, String.class, templateWithContentType("text/xml"))).isTrue(); + assertThat( + encoder.canEncode(null, String.class, templateWithContentType("application/soap+xml"))) + .isTrue(); + assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/json"))) + .isFalse(); + } + + @Test + void contentTypeHeaderNameIsMatchedCaseInsensitively() { + PredicatedEncoder encoder = PredicatedEncoder.forJsonContentType(new RecordingEncoder()); + + RequestTemplate template = new RequestTemplate(); + template.header("content-type", "application/json"); + + assertThat(encoder.canEncode(null, String.class, template)).isTrue(); + } + + @Test + void forEmptyBodyMatchesNullBodyOnly() { + PredicatedEncoder encoder = PredicatedEncoder.forEmptyBody(new RecordingEncoder()); + + assertThat(encoder.canEncode(null, String.class, templateWithContentType(null))).isTrue(); + assertThat(encoder.canEncode("body", String.class, templateWithContentType(null))).isFalse(); + } + + @Test + void rejectsNullConstructorArguments() { + assertThatThrownBy(() -> new PredicatedEncoder(null, new RecordingEncoder())) + .isInstanceOf(NullPointerException.class) + .hasMessage("predicate cannot be null"); + assertThatThrownBy(() -> new PredicatedEncoder((o, t, tpl) -> true, null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("delegate cannot be null"); + } +} From df9ae654cae58eb0b5b4b7275ba11503708f9181 Mon Sep 17 00:00:00 2001 From: Yevhen Vasyliev Date: Wed, 19 Aug 2026 09:27:30 -0300 Subject: [PATCH 30/45] Add MultiEncoder to select an encoder per request Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder --- .../main/java/feign/codec/MultiEncoder.java | 105 +++++++++++ .../java/feign/codec/MultiEncoderTest.java | 178 ++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100644 core/src/main/java/feign/codec/MultiEncoder.java create mode 100644 core/src/test/java/feign/codec/MultiEncoderTest.java diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java new file mode 100644 index 000000000..aa779930a --- /dev/null +++ b/core/src/main/java/feign/codec/MultiEncoder.java @@ -0,0 +1,105 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.RequestTemplate; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * An encoder that delegates to a list of {@link PredicatedEncoder}s, using the first one whose + * predicate accepts the request, and falling back to a default encoder when none do. + * + *

The default encoder is declared first so the predicated ones can be supplied as varargs, but + * it is consulted last — it is the fallback, not the first choice. + * + *

+ * Encoder encoder =
+ *     MultiEncoder.of(
+ *         new DefaultEncoder(),
+ *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
+ *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()));
+ * 
+ */ +public class MultiEncoder implements Encoder { + + private final Encoder defaultEncoder; + + private final List delegates; + + /** + * Creates an encoder that tries each predicated encoder in order and falls back to {@code + * defaultEncoder}. + * + * @param defaultEncoder the encoder used when no predicate accepts the request + * @param encoders the predicated encoders, consulted in the order given + * @return the multi-encoder + */ + public static Encoder of(Encoder defaultEncoder, PredicatedEncoder... encoders) { + return of(defaultEncoder, Arrays.asList(encoders)); + } + + /** + * Creates an encoder that tries each predicated encoder in order and falls back to {@code + * defaultEncoder}. + * + * @param defaultEncoder the encoder used when no predicate accepts the request + * @param encoders the predicated encoders, consulted in the order given + * @return the multi-encoder + */ + public static Encoder of(Encoder defaultEncoder, List encoders) { + return new MultiEncoder(defaultEncoder, encoders); + } + + private MultiEncoder(Encoder defaultEncoder, List delegates) { + this.defaultEncoder = Objects.requireNonNull(defaultEncoder, "defaultEncoder cannot be null"); + Objects.requireNonNull(delegates, "delegates cannot be null"); + for (PredicatedEncoder delegate : delegates) { + Objects.requireNonNull(delegate, "delegates cannot contain null"); + } + this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); + } + + /** + * Encodes using the first delegate whose predicate accepts the request, or the default encoder if + * none do. + * + * @param object {@inheritDoc} + * @param bodyType {@inheritDoc} + * @param template {@inheritDoc} + * @throws EncodeException {@inheritDoc} + */ + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + for (PredicatedEncoder delegate : delegates) { + if (delegate.canEncode(object, bodyType, template)) { + delegate.encode(object, bodyType, template); + return; + } + } + defaultEncoder.encode(object, bodyType, template); + } + + @Override + public String toString() { + return "MultiEncoder{defaultEncoder=" + defaultEncoder + ", delegates=" + delegates + '}'; + } +} diff --git a/core/src/test/java/feign/codec/MultiEncoderTest.java b/core/src/test/java/feign/codec/MultiEncoderTest.java new file mode 100644 index 000000000..a02f53eed --- /dev/null +++ b/core/src/test/java/feign/codec/MultiEncoderTest.java @@ -0,0 +1,178 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.Request; +import feign.RequestTemplate; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class MultiEncoderTest { + + private static class RecordingEncoder implements Encoder { + private final String body; + boolean invoked; + + RecordingEncoder(String body) { + this.body = body; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + invoked = true; + template.body(Request.Body.create(body)); + } + } + + private static RequestTemplate templateWithContentType(String contentType) { + RequestTemplate template = new RequestTemplate(); + if (contentType != null) { + template.header("Content-Type", contentType); + } + return template; + } + + @Test + void usesFirstDelegateWhosePredicateAccepts() { + RecordingEncoder json = new RecordingEncoder("json"); + RecordingEncoder xml = new RecordingEncoder("xml"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.of( + fallback, + PredicatedEncoder.forJsonContentType(json), + PredicatedEncoder.forXmlContentType(xml)); + + RequestTemplate template = templateWithContentType("application/json"); + encoder.encode("body", String.class, template); + + assertThat(json.invoked).isTrue(); + assertThat(xml.invoked).isFalse(); + assertThat(fallback.invoked).isFalse(); + assertThat(template.requestBody().asString()).isEqualTo("json"); + } + + @Test + void matchesSuffixedContentTypes() { + RecordingEncoder json = new RecordingEncoder("json"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + + encoder.encode("body", String.class, templateWithContentType("application/vnd.github+json")); + + assertThat(json.invoked).isTrue(); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void fallsBackToDefaultEncoderWhenNoPredicateAccepts() { + RecordingEncoder json = new RecordingEncoder("json"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + + RequestTemplate template = templateWithContentType("text/plain"); + encoder.encode("body", String.class, template); + + assertThat(json.invoked).isFalse(); + assertThat(fallback.invoked).isTrue(); + assertThat(template.requestBody().asString()).isEqualTo("fallback"); + } + + @Test + void fallsBackToDefaultEncoderWhenNoContentTypeIsSet() { + RecordingEncoder json = new RecordingEncoder("json"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + + encoder.encode("body", String.class, templateWithContentType(null)); + + assertThat(json.invoked).isFalse(); + assertThat(fallback.invoked).isTrue(); + } + + @Test + void withoutDelegatesEverythingGoesToTheDefaultEncoder() { + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = MultiEncoder.of(fallback); + + encoder.encode("body", String.class, templateWithContentType("application/json")); + + assertThat(fallback.invoked).isTrue(); + } + + @Test + void propagatesEncodeExceptionFromDelegate() { + Encoder failing = + (object, bodyType, template) -> { + throw new EncodeException("boom"); + }; + + Encoder encoder = + MultiEncoder.of(new DefaultEncoder(), PredicatedEncoder.forJsonContentType(failing)); + + assertThatThrownBy( + () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) + .isInstanceOf(EncodeException.class) + .hasMessage("boom"); + } + + @Test + void rejectsNullDefaultEncoder() { + assertThatThrownBy(() -> MultiEncoder.of(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("defaultEncoder cannot be null"); + } + + @Test + void rejectsNullDelegate() { + assertThatThrownBy(() -> MultiEncoder.of(new DefaultEncoder(), Collections.singletonList(null))) + .isInstanceOf(NullPointerException.class) + .hasMessage("delegates cannot contain null"); + } + + @Test + void toStringDescribesDelegates() { + Encoder encoder = + MultiEncoder.of( + new DefaultEncoder(), PredicatedEncoder.forJsonContentType(new DefaultEncoder())); + + assertThat(encoder.toString()).startsWith("MultiEncoder{defaultEncoder="); + assertThat(encoder.toString()).contains("PredicatedEncoder{"); + } + + @Test + void listFactoryIsEquivalentToVarargs() { + RecordingEncoder json = new RecordingEncoder("json"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.of(fallback, Arrays.asList(PredicatedEncoder.forJsonContentType(json))); + + encoder.encode("body", String.class, templateWithContentType("application/json")); + + assertThat(json.invoked).isTrue(); + } +} From 0ece2841f516c421784b769abdca2187de886af8 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 09:27:35 -0300 Subject: [PATCH 31/45] Expose and document multi-encoder configuration Co-authored-by: Yevhen Vasyliev Co-authored-by: trumpetinc <6618744+trumpetinc@users.noreply.github.com> Signed-off-by: Marvin Froeder --- CHANGELOG.md | 6 + README.md | 48 ++++++++ core/src/main/java/feign/BaseBuilder.java | 23 ++++ src/docs/overview-mindmap.iuml | 127 +++++++++++----------- 4 files changed, 141 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513e923ab..b3ff46675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ### Version 13.14 +* Add `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single client pick an + encoder per request. `Feign.builder().encoder(defaultEncoder, predicatedEncoders...)` builds one; + predicates are consulted in order and the default encoder is the fallback. `Util.isJsonContentType` + and `Util.isXmlContentType` back the `PredicatedEncoder.forJsonContentType`/`forXmlContentType` + factories. The `Encoder` interface is unchanged, so existing encoders keep working (#3485). + * `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only Marshallers. Marshaller-only properties are skipped on unmarshal (#3056). * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a diff --git a/README.md b/README.md index cbaae1076..d03bf003c 100644 --- a/README.md +++ b/README.md @@ -709,6 +709,54 @@ public class Example { } ``` +#### Multiple encoders + +A single client sometimes has to speak more than one format — JSON for most endpoints, XML for +a legacy one, plain bytes for an upload. `MultiEncoder` picks the encoder per request by asking each +candidate's predicate, falling back to a default encoder when none match. + +```java +interface MixedClient { + @RequestLine("POST /orders") + @Headers("Content-Type: application/json") + void createOrder(Order order); + + @RequestLine("POST /legacy/orders") + @Headers("Content-Type: application/xml") + void createLegacyOrder(Order order); +} + +public class Example { + public static void main(String[] args) { + MixedClient client = Feign.builder() + .encoder( + new DefaultEncoder(), + PredicatedEncoder.forJsonContentType(new GsonEncoder()), + PredicatedEncoder.forXmlContentType(new JAXBEncoder())) + .target(MixedClient.class, "https://foo.com"); + } +} +``` + +The first argument is the default encoder, used when no predicate accepts the request. The remaining +arguments are consulted in the order given, so the most specific encoder should come first. + +`PredicatedEncoder` ships with factories for the common cases — `forJsonContentType`, +`forXmlContentType` and `forEmptyBody`. `EncoderPredicate` is a functional interface, so any other +condition is a lambda over the same three arguments `Encoder#encode` receives: + +```java +Encoder encoder = + MultiEncoder.of( + new DefaultEncoder(), + new PredicatedEncoder( + (object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder()), + PredicatedEncoder.forJsonContentType(new GsonEncoder())); +``` + +A `PredicatedEncoder` used on its own, outside a `MultiEncoder`, is strict: a request its predicate +rejects raises `EncodeException` rather than silently encoding nothing. + ### @Body templates The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. You will likely need to add a `Content-Type` header. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index 754fcd306..8d07b31dd 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -27,6 +27,8 @@ import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; import feign.codec.ErrorDecoder; +import feign.codec.MultiEncoder; +import feign.codec.PredicatedEncoder; import feign.interceptor.MethodInterceptor; import feign.interceptor.MethodInterceptors; import java.lang.reflect.Field; @@ -94,6 +96,27 @@ public B encoder(Encoder encoder) { return thisB(); } + /** + * Configures a {@link MultiEncoder} that picks an encoder per request. + * + *

Each {@link PredicatedEncoder} is consulted in the order given; {@code defaultEncoder} is + * the fallback used when no predicate accepts the request. + * + *

+   * Feign.builder()
+   *     .encoder(
+   *         new DefaultEncoder(),
+   *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
+   *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()))
+   * 
+ * + * @param defaultEncoder the encoder used when no predicate accepts the request + * @param encoders the predicated encoders, consulted in the order given + */ + public B encoder(Encoder defaultEncoder, PredicatedEncoder... encoders) { + return encoder(MultiEncoder.of(defaultEncoder, encoders)); + } + public B decoder(Decoder decoder) { this.decoder = decoder; return thisB(); diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index afd6aefbf..805b77db3 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -1,63 +1,64 @@ -@startmindmap -* Feign -** clients -*** java.net.URL -*** Apache HTTP -*** Apache HC5 -*** Google HTTP -*** Java 11 Http2 -*** OK Http -*** Ribbon -** async clients -*** java.net.URL -*** Apache HC5 -*** OkHttp -*** Vertx -*** Reactive Wrappers -** contracts -*** Feign -*** JAX-RS -*** JAX-RS 2 -*** JAX-RS 3 / Jakarta -*** JAX-RS 4 -*** Spring -*** SOAP -*** SOAP Jakarta -*** Spring boot (3rd party) -** language -*** Kotlin -*** GraphQL - -left side - -** encoders/decoders -*** GSON -*** JAXB -*** JAXB Jakarta -*** Jackson -*** Jackson 3 -*** Jackson JAXB -*** Jackson Jr -*** Sax -*** JSON-java -*** Moshi -*** Fastjson2 -*** Form -*** Form Spring -** metrics -*** Dropwizard Metrics 4 -*** Dropwizard Metrics 5 -*** Micrometer -** interceptors -*** RequestInterceptor -*** ResponseInterceptor -*** MethodInterceptor -**** Bean Validation (JSR-303) -**** Bean Validation (Jakarta) -**** HTTP Cache (ETag / Last-Modified) -** extras -*** Hystrix -*** SLF4J -*** Mock -*** Annotation Error Decoder -@endmindmap +@startmindmap +* Feign +** clients +*** java.net.URL +*** Apache HTTP +*** Apache HC5 +*** Google HTTP +*** Java 11 Http2 +*** OK Http +*** Ribbon +** async clients +*** java.net.URL +*** Apache HC5 +*** OkHttp +*** Vertx +*** Reactive Wrappers +** contracts +*** Feign +*** JAX-RS +*** JAX-RS 2 +*** JAX-RS 3 / Jakarta +*** JAX-RS 4 +*** Spring +*** SOAP +*** SOAP Jakarta +*** Spring boot (3rd party) +** language +*** Kotlin +*** GraphQL + +left side + +** encoders/decoders +*** Multi encoder (predicate based) +*** GSON +*** JAXB +*** JAXB Jakarta +*** Jackson +*** Jackson 3 +*** Jackson JAXB +*** Jackson Jr +*** Sax +*** JSON-java +*** Moshi +*** Fastjson2 +*** Form +*** Form Spring +** metrics +*** Dropwizard Metrics 4 +*** Dropwizard Metrics 5 +*** Micrometer +** interceptors +*** RequestInterceptor +*** ResponseInterceptor +*** MethodInterceptor +**** Bean Validation (JSR-303) +**** Bean Validation (Jakarta) +**** HTTP Cache (ETag / Last-Modified) +** extras +*** Hystrix +*** SLF4J +*** Mock +*** Annotation Error Decoder +@endmindmap From 78eab43b6282330eb7423d892cc0daf2a86806bb Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 09:57:11 -0300 Subject: [PATCH 32/45] Mark the multi-encoder API as experimental Signed-off-by: Marvin Froeder --- CHANGELOG.md | 2 +- README.md | 2 ++ core/src/main/java/feign/BaseBuilder.java | 1 + core/src/main/java/feign/Util.java | 2 ++ core/src/main/java/feign/codec/EncoderPredicate.java | 2 ++ core/src/main/java/feign/codec/MultiEncoder.java | 2 ++ core/src/main/java/feign/codec/PredicatedEncoder.java | 2 ++ src/docs/overview-mindmap.iuml | 2 +- 8 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3ff46675..14e5619a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ### Version 13.14 -* Add `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single client pick an +* Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single client pick an encoder per request. `Feign.builder().encoder(defaultEncoder, predicatedEncoders...)` builds one; predicates are consulted in order and the default encoder is the fallback. `Util.isJsonContentType` and `Util.isXmlContentType` back the `PredicatedEncoder.forJsonContentType`/`forXmlContentType` diff --git a/README.md b/README.md index d03bf003c..a66ad92e3 100644 --- a/README.md +++ b/README.md @@ -711,6 +711,8 @@ public class Example { #### Multiple encoders +> This API is `@Experimental` and may change incompatibly, or be removed, in a future release. + A single client sometimes has to speak more than one format — JSON for most endpoints, XML for a legacy one, plain bytes for an upload. `MultiEncoder` picks the encoder per request by asking each candidate's predicate, falling back to a default encoder when none match. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index 8d07b31dd..abc246db3 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -113,6 +113,7 @@ public B encoder(Encoder encoder) { * @param defaultEncoder the encoder used when no predicate accepts the request * @param encoders the predicated encoders, consulted in the order given */ + @Experimental public B encoder(Encoder defaultEncoder, PredicatedEncoder... encoders) { return encoder(MultiEncoder.of(defaultEncoder, encoders)); } diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index b91144242..2b4b8d5bd 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -394,6 +394,7 @@ public static String getThreadIdentifier() { * @param template the request template to check * @return {@code true} if the content type is JSON, {@code false} otherwise */ + @Experimental public static boolean isJsonContentType(RequestTemplate template) { return hasContentTypeMatching(template, JSON_CONTENT_TYPE); } @@ -407,6 +408,7 @@ public static boolean isJsonContentType(RequestTemplate template) { * @param template the request template to check * @return {@code true} if the content type is XML, {@code false} otherwise */ + @Experimental public static boolean isXmlContentType(RequestTemplate template) { return hasContentTypeMatching(template, XML_CONTENT_TYPE); } diff --git a/core/src/main/java/feign/codec/EncoderPredicate.java b/core/src/main/java/feign/codec/EncoderPredicate.java index a00ceb263..16a9b2550 100644 --- a/core/src/main/java/feign/codec/EncoderPredicate.java +++ b/core/src/main/java/feign/codec/EncoderPredicate.java @@ -15,6 +15,7 @@ */ package feign.codec; +import feign.Experimental; import feign.RequestTemplate; import java.lang.reflect.Type; @@ -29,6 +30,7 @@ * @see MultiEncoder */ @FunctionalInterface +@Experimental public interface EncoderPredicate { /** diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java index aa779930a..494fad00d 100644 --- a/core/src/main/java/feign/codec/MultiEncoder.java +++ b/core/src/main/java/feign/codec/MultiEncoder.java @@ -15,6 +15,7 @@ */ package feign.codec; +import feign.Experimental; import feign.RequestTemplate; import java.lang.reflect.Type; import java.util.ArrayList; @@ -38,6 +39,7 @@ * PredicatedEncoder.forXmlContentType(new JAXBEncoder())); * */ +@Experimental public class MultiEncoder implements Encoder { private final Encoder defaultEncoder; diff --git a/core/src/main/java/feign/codec/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java index f70b96133..b8c130f72 100644 --- a/core/src/main/java/feign/codec/PredicatedEncoder.java +++ b/core/src/main/java/feign/codec/PredicatedEncoder.java @@ -15,6 +15,7 @@ */ package feign.codec; +import feign.Experimental; import feign.RequestTemplate; import feign.Util; import java.lang.reflect.Type; @@ -36,6 +37,7 @@ * PredicatedEncoder.forXmlContentType(new JAXBEncoder())) * */ +@Experimental public class PredicatedEncoder implements Encoder { private final EncoderPredicate predicate; diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index 805b77db3..50dd352f2 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -31,7 +31,7 @@ left side ** encoders/decoders -*** Multi encoder (predicate based) +*** Multi encoder (predicate based, experimental) *** GSON *** JAXB *** JAXB Jakarta From fecafdb998ba876a0b14b274cd93f1922410d30e Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 10:25:56 -0300 Subject: [PATCH 33/45] Rework multi-encoder around encoders that declare their own canEncode Signed-off-by: Marvin Froeder --- CHANGELOG.md | 17 +- README.md | 60 +++++-- core/src/main/java/feign/BaseBuilder.java | 20 ++- core/src/main/java/feign/Util.java | 31 +++- .../java/feign/codec/EncoderPredicate.java | 61 ++++++- .../main/java/feign/codec/MultiEncoder.java | 130 +++++++++----- .../java/feign/codec/PredicatedEncoder.java | 86 +++------ .../feign/codec/EncoderPredicateTest.java | 115 ++++++++++++ .../codec/MultiEncoderCapabilityTest.java | 170 ++++++++++++++++++ .../java/feign/codec/MultiEncoderTest.java | 161 ++++++++++++----- .../feign/codec/PredicatedEncoderTest.java | 138 -------------- .../java/feign/metrics4/MeteredEncoder.java | 9 +- .../java/feign/metrics5/MeteredEncoder.java | 9 +- .../feign/fastjson2/Fastjson2Encoder.java | 8 +- .../src/main/java/feign/gson/GsonEncoder.java | 9 +- .../jackson/jaxb/JacksonJaxbJsonEncoder.java | 9 +- .../feign/jackson/jr/JacksonJrEncoder.java | 9 +- .../java/feign/jackson/JacksonEncoder.java | 8 +- .../java/feign/jackson3/Jackson3Encoder.java | 8 +- .../src/main/java/feign/jaxb/JAXBEncoder.java | 9 +- .../src/main/java/feign/jaxb/JAXBEncoder.java | 9 +- .../src/main/java/feign/json/JsonEncoder.java | 9 +- .../java/feign/micrometer/MeteredEncoder.java | 9 +- .../main/java/feign/moshi/MoshiEncoder.java | 9 +- .../src/main/java/feign/soap/SOAPEncoder.java | 9 +- .../src/main/java/feign/soap/SOAPEncoder.java | 9 +- src/docs/overview-mindmap.iuml | 128 ++++++------- 27 files changed, 842 insertions(+), 407 deletions(-) create mode 100644 core/src/test/java/feign/codec/EncoderPredicateTest.java create mode 100644 core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java delete mode 100644 core/src/test/java/feign/codec/PredicatedEncoderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e5619a3..804504da5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,14 @@ ### Version 13.14 -* Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single client pick an - encoder per request. `Feign.builder().encoder(defaultEncoder, predicatedEncoders...)` builds one; - predicates are consulted in order and the default encoder is the fallback. `Util.isJsonContentType` - and `Util.isXmlContentType` back the `PredicatedEncoder.forJsonContentType`/`forXmlContentType` - factories. The `Encoder` interface is unchanged, so existing encoders keep working (#3485). - -* `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only - Marshallers. Marshaller-only properties are skipped on unmarshal (#3056). +* Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single + client route each request to the right encoder. Encoders declare what they can handle by + implementing `PredicatedEncoder`; anything else is paired with a predicate via + `MultiEncoder.builder(defaultEncoder)`. The first-party JSON encoders (Gson, Jackson, Jackson 3, + Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, + SOAP Jakarta) now declare themselves, and the metrics modules' `MeteredEncoder` forwards + `canEncode` to the encoder it wraps. The `Encoder` interface is unchanged, so existing encoders + keep working (#3485). + * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and incorporates a body hash into the cache key to reduce cross-body collisions. diff --git a/README.md b/README.md index a66ad92e3..7925caa30 100644 --- a/README.md +++ b/README.md @@ -714,8 +714,10 @@ public class Example { > This API is `@Experimental` and may change incompatibly, or be removed, in a future release. A single client sometimes has to speak more than one format — JSON for most endpoints, XML for -a legacy one, plain bytes for an upload. `MultiEncoder` picks the encoder per request by asking each -candidate's predicate, falling back to a default encoder when none match. +a legacy one, plain bytes for an upload. `MultiEncoder` routes each request to the right encoder, +falling back to a default when none applies. + +Most first-party encoders already declare what they can handle, so they can simply be added: ```java interface MixedClient { @@ -731,33 +733,55 @@ interface MixedClient { public class Example { public static void main(String[] args) { MixedClient client = Feign.builder() - .encoder( - new DefaultEncoder(), - PredicatedEncoder.forJsonContentType(new GsonEncoder()), - PredicatedEncoder.forXmlContentType(new JAXBEncoder())) + .encoder(new DefaultEncoder(), new GsonEncoder(), new JAXBEncoder()) .target(MixedClient.class, "https://foo.com"); } } ``` -The first argument is the default encoder, used when no predicate accepts the request. The remaining -arguments are consulted in the order given, so the most specific encoder should come first. +The first argument is the default encoder, used when nothing else accepts the request. -`PredicatedEncoder` ships with factories for the common cases — `forJsonContentType`, -`forXmlContentType` and `forEmptyBody`. `EncoderPredicate` is a functional interface, so any other -condition is a lambda over the same three arguments `Encoder#encode` receives: +For an encoder that does not declare itself — including one you do not control — pair it +with an `EncoderPredicate` using the builder: ```java Encoder encoder = - MultiEncoder.of( - new DefaultEncoder(), - new PredicatedEncoder( - (object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder()), - PredicatedEncoder.forJsonContentType(new GsonEncoder())); + MultiEncoder.builder(new DefaultEncoder()) + .add(new GsonEncoder()) // declares itself + .add(EncoderPredicate.xmlContentType(), someXmlEncoder) // paired + .add((object, bodyType, template) -> bodyType == byte[].class, binaryEncoder) + .build(); ``` -A `PredicatedEncoder` used on its own, outside a `MultiEncoder`, is strict: a request its predicate -rejects raises `EncodeException` rather than silently encoding nothing. +Delegates are consulted in the order they were added, so put the narrowest predicate first. Note +that `Content-Type: application/json` with a null body is claimed by a JSON encoder before +`EncoderPredicate.emptyBody()` gets a chance — order accordingly. + +##### Declaring your own encoder + +Implement `PredicatedEncoder` alongside `Encoder` and override `canEncode`: + +```java +public class MyEncoder implements Encoder, PredicatedEncoder { + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + // ... + } +} +``` + +`EncoderPredicate` ships with `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, +`emptyBody()`, `bodyType(type)` and `formEncoded()`, plus `and`/`or`/`negate` to combine them. + +**If you wrap an encoder, forward `canEncode` to your delegate.** A wrapper that does not will claim +every request, because the default `canEncode` accepts everything. The metrics modules' +`MeteredEncoder` forwards for exactly this reason. ### @Body templates The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. You will likely need to add a `Content-Type` header. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index abc246db3..12cbf9c3c 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -97,25 +97,29 @@ public B encoder(Encoder encoder) { } /** - * Configures a {@link MultiEncoder} that picks an encoder per request. + * Configures a {@link MultiEncoder} built from encoders that declare their own applicability. * *

Each {@link PredicatedEncoder} is consulted in the order given; {@code defaultEncoder} is - * the fallback used when no predicate accepts the request. + * the fallback used when none accepts the request. * *

    * Feign.builder()
-   *     .encoder(
-   *         new DefaultEncoder(),
-   *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
-   *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()))
+   *     .encoder(new DefaultEncoder(), new JacksonEncoder(), new JAXBEncoder())
    * 
* - * @param defaultEncoder the encoder used when no predicate accepts the request + *

To pair a predicate with an encoder that does not implement {@link PredicatedEncoder}, use + * {@link MultiEncoder#builder(Encoder)} instead. + * + * @param defaultEncoder the encoder used when no delegate accepts the request * @param encoders the predicated encoders, consulted in the order given */ @Experimental public B encoder(Encoder defaultEncoder, PredicatedEncoder... encoders) { - return encoder(MultiEncoder.of(defaultEncoder, encoders)); + MultiEncoder.Builder builder = MultiEncoder.builder(defaultEncoder); + for (PredicatedEncoder encoder : encoders) { + builder.add(encoder); + } + return encoder(builder.build()); } public B decoder(Decoder decoder) { diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index 2b4b8d5bd..589ae406c 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -413,13 +413,38 @@ public static boolean isXmlContentType(RequestTemplate template) { return hasContentTypeMatching(template, XML_CONTENT_TYPE); } - private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) { + /** + * Checks whether the {@code Content-Type} header of the given template starts with the given + * media type, ignoring case and any parameters such as {@code ;charset=utf-8}. + * + * @param template the request template to check + * @param mediaType the media type to look for, for example {@code + * application/x-www-form-urlencoded} + * @return {@code true} if the content type matches, {@code false} otherwise + */ + @Experimental + public static boolean hasContentType(RequestTemplate template, String mediaType) { + return contentTypes(template) + .anyMatch( + contentType -> { + String trimmed = contentType.trim(); + return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length()) + && (trimmed.length() == mediaType.length() + || trimmed.charAt(mediaType.length()) == ';'); + }); + } + + private static Stream contentTypes(RequestTemplate template) { return template.headers().entrySet().stream() .filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey())) .map(Map.Entry::getValue) .filter(Objects::nonNull) .flatMap(Collection::stream) - .anyMatch( - contentType -> contentType != null && pattern.matcher(contentType.trim()).matches()); + .filter(Objects::nonNull); + } + + private static boolean hasContentTypeMatching(RequestTemplate template, Pattern pattern) { + return contentTypes(template) + .anyMatch(contentType -> pattern.matcher(contentType.trim()).matches()); } } diff --git a/core/src/main/java/feign/codec/EncoderPredicate.java b/core/src/main/java/feign/codec/EncoderPredicate.java index 16a9b2550..1383dae47 100644 --- a/core/src/main/java/feign/codec/EncoderPredicate.java +++ b/core/src/main/java/feign/codec/EncoderPredicate.java @@ -17,10 +17,12 @@ import feign.Experimental; import feign.RequestTemplate; +import feign.Util; import java.lang.reflect.Type; +import java.util.Objects; /** - * A predicate that decides whether a given request can be handled by an {@link Encoder}. + * Decides whether a request can be handled by an {@link Encoder}. * *

Predicates receive the same three arguments as {@link Encoder#encode(Object, Type, * RequestTemplate)}, so they can discriminate on the body, on its declared type, or on anything @@ -29,12 +31,12 @@ * @see PredicatedEncoder * @see MultiEncoder */ -@FunctionalInterface @Experimental +@FunctionalInterface public interface EncoderPredicate { /** - * Tests whether the given request can be encoded. + * Whether the encoder this predicate guards can handle the request. * * @param object what would be encoded as the request body * @param bodyType the type the object would be encoded as. {@link Encoder#MAP_STRING_WILDCARD} @@ -42,5 +44,56 @@ public interface EncoderPredicate { * @param template the request template that would be populated * @return {@code true} if the request can be encoded, {@code false} otherwise */ - boolean test(Object object, Type bodyType, RequestTemplate template); + boolean canEncode(Object object, Type bodyType, RequestTemplate template); + + /** Matches requests whose {@code Content-Type} header denotes JSON. */ + static EncoderPredicate jsonContentType() { + return (object, bodyType, template) -> Util.isJsonContentType(template); + } + + /** Matches requests whose {@code Content-Type} header denotes XML. */ + static EncoderPredicate xmlContentType() { + return (object, bodyType, template) -> Util.isXmlContentType(template); + } + + /** + * Matches requests whose {@code Content-Type} header starts with the given media type, ignoring + * case and any parameters such as {@code ;charset=utf-8}. + */ + static EncoderPredicate contentType(String mediaType) { + Objects.requireNonNull(mediaType, "mediaType cannot be null"); + return (object, bodyType, template) -> Util.hasContentType(template, mediaType); + } + + /** Matches requests carrying no body. */ + static EncoderPredicate emptyBody() { + return (object, bodyType, template) -> object == null; + } + + /** Matches requests whose declared body type is exactly the given type. */ + static EncoderPredicate bodyType(Type type) { + Objects.requireNonNull(type, "type cannot be null"); + return (object, bodyType, template) -> type.equals(bodyType); + } + + /** Matches form-encoded requests, as signalled by {@link Encoder#MAP_STRING_WILDCARD}. */ + static EncoderPredicate formEncoded() { + return (object, bodyType, template) -> Encoder.MAP_STRING_WILDCARD.equals(bodyType); + } + + default EncoderPredicate and(EncoderPredicate other) { + Objects.requireNonNull(other, "other cannot be null"); + return (object, bodyType, template) -> + canEncode(object, bodyType, template) && other.canEncode(object, bodyType, template); + } + + default EncoderPredicate or(EncoderPredicate other) { + Objects.requireNonNull(other, "other cannot be null"); + return (object, bodyType, template) -> + canEncode(object, bodyType, template) || other.canEncode(object, bodyType, template); + } + + default EncoderPredicate negate() { + return (object, bodyType, template) -> !canEncode(object, bodyType, template); + } } diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java index 494fad00d..c2ab15e8e 100644 --- a/core/src/main/java/feign/codec/MultiEncoder.java +++ b/core/src/main/java/feign/codec/MultiEncoder.java @@ -19,69 +19,58 @@ import feign.RequestTemplate; import java.lang.reflect.Type; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Objects; /** - * An encoder that delegates to a list of {@link PredicatedEncoder}s, using the first one whose - * predicate accepts the request, and falling back to a default encoder when none do. + * An {@link Encoder} that selects a delegate per request, falling back to a default encoder when no + * delegate accepts it. * - *

The default encoder is declared first so the predicated ones can be supplied as varargs, but - * it is consulted last — it is the fallback, not the first choice. + *

Delegates come from two places. An encoder that implements {@link PredicatedEncoder} declares + * its own applicability and can simply be added; any other encoder is paired with an {@link + * EncoderPredicate} at the call site: * *

- * Encoder encoder =
- *     MultiEncoder.of(
- *         new DefaultEncoder(),
- *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
- *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()));
+ * Feign.builder()
+ *     .encoder(
+ *         MultiEncoder.builder(new DefaultEncoder())
+ *             .add(new JacksonEncoder())
+ *             .add(EncoderPredicate.xmlContentType(), new JAXBEncoder())
+ *             .add((object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder())
+ *             .build());
  * 
+ * + *

Delegates are consulted in the order they were added, so the narrowest predicate should come + * first. The default encoder is consulted last. + * + * @see PredicatedEncoder + * @see EncoderPredicate */ @Experimental public class MultiEncoder implements Encoder { private final Encoder defaultEncoder; - private final List delegates; + private final List delegates; - /** - * Creates an encoder that tries each predicated encoder in order and falls back to {@code - * defaultEncoder}. - * - * @param defaultEncoder the encoder used when no predicate accepts the request - * @param encoders the predicated encoders, consulted in the order given - * @return the multi-encoder - */ - public static Encoder of(Encoder defaultEncoder, PredicatedEncoder... encoders) { - return of(defaultEncoder, Arrays.asList(encoders)); + private MultiEncoder(Encoder defaultEncoder, List delegates) { + this.defaultEncoder = defaultEncoder; + this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); } /** - * Creates an encoder that tries each predicated encoder in order and falls back to {@code - * defaultEncoder}. + * Starts building a multi-encoder. * - * @param defaultEncoder the encoder used when no predicate accepts the request - * @param encoders the predicated encoders, consulted in the order given - * @return the multi-encoder + * @param defaultEncoder the encoder used when no delegate accepts the request + * @return the builder */ - public static Encoder of(Encoder defaultEncoder, List encoders) { - return new MultiEncoder(defaultEncoder, encoders); - } - - private MultiEncoder(Encoder defaultEncoder, List delegates) { - this.defaultEncoder = Objects.requireNonNull(defaultEncoder, "defaultEncoder cannot be null"); - Objects.requireNonNull(delegates, "delegates cannot be null"); - for (PredicatedEncoder delegate : delegates) { - Objects.requireNonNull(delegate, "delegates cannot contain null"); - } - this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); + public static Builder builder(Encoder defaultEncoder) { + return new Builder(defaultEncoder); } /** - * Encodes using the first delegate whose predicate accepts the request, or the default encoder if - * none do. + * Encodes using the first delegate that accepts the request, or the default encoder if none do. * * @param object {@inheritDoc} * @param bodyType {@inheritDoc} @@ -91,9 +80,9 @@ private MultiEncoder(Encoder defaultEncoder, List delegates) @Override public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { - for (PredicatedEncoder delegate : delegates) { - if (delegate.canEncode(object, bodyType, template)) { - delegate.encode(object, bodyType, template); + for (Delegate delegate : delegates) { + if (delegate.predicate.canEncode(object, bodyType, template)) { + delegate.encoder.encode(object, bodyType, template); return; } } @@ -104,4 +93,61 @@ public void encode(Object object, Type bodyType, RequestTemplate template) public String toString() { return "MultiEncoder{defaultEncoder=" + defaultEncoder + ", delegates=" + delegates + '}'; } + + private static final class Delegate { + private final EncoderPredicate predicate; + private final Encoder encoder; + + Delegate(EncoderPredicate predicate, Encoder encoder) { + this.predicate = predicate; + this.encoder = encoder; + } + + @Override + public String toString() { + return encoder.toString(); + } + } + + /** Collects the delegates of a {@link MultiEncoder}. */ + @Experimental + public static final class Builder { + + private final Encoder defaultEncoder; + + private final List delegates = new ArrayList<>(); + + private Builder(Encoder defaultEncoder) { + this.defaultEncoder = Objects.requireNonNull(defaultEncoder, "defaultEncoder cannot be null"); + } + + /** + * Adds an encoder that declares its own applicability. + * + * @param encoder the encoder, consulted via {@link PredicatedEncoder#canEncode} + */ + public Builder add(PredicatedEncoder encoder) { + Objects.requireNonNull(encoder, "encoder cannot be null"); + return add(encoder::canEncode, encoder); + } + + /** + * Adds any encoder, guarded by the given predicate. Use this for encoders that do not implement + * {@link PredicatedEncoder}, including ones you do not control. + * + * @param predicate decides whether the encoder handles a request + * @param encoder the encoder to delegate to + */ + public Builder add(EncoderPredicate predicate, Encoder encoder) { + Objects.requireNonNull(predicate, "predicate cannot be null"); + Objects.requireNonNull(encoder, "encoder cannot be null"); + delegates.add(new Delegate(predicate, encoder)); + return this; + } + + /** Builds the multi-encoder. */ + public MultiEncoder build() { + return new MultiEncoder(defaultEncoder, delegates); + } + } } diff --git a/core/src/main/java/feign/codec/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java index b8c130f72..c97cfd072 100644 --- a/core/src/main/java/feign/codec/PredicatedEncoder.java +++ b/core/src/main/java/feign/codec/PredicatedEncoder.java @@ -17,79 +17,47 @@ import feign.Experimental; import feign.RequestTemplate; -import feign.Util; import java.lang.reflect.Type; -import java.util.Objects; /** - * Pairs an {@link EncoderPredicate} with the {@link Encoder} it guards, so that a {@link - * MultiEncoder} can pick the right encoder per request. + * An {@link Encoder} that knows which requests it can handle. * - *

Encoding through a {@code PredicatedEncoder} directly is allowed but strict: a request its - * predicate rejects raises {@link EncodeException} rather than silently doing nothing. Inside a - * {@link MultiEncoder} a rejected request simply moves on to the next candidate. + *

Encoders implement this to declare their own applicability, so a {@link MultiEncoder} can + * route each request to the right one without the call site having to wrap anything: * *

- * Feign.builder()
- *     .encoder(
- *         new DefaultEncoder(),
- *         PredicatedEncoder.forJsonContentType(new JacksonEncoder()),
- *         PredicatedEncoder.forXmlContentType(new JAXBEncoder()))
+ * public class JacksonEncoder implements Encoder, PredicatedEncoder {
+ *
+ *   @Override
+ *   public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
+ *     return EncoderPredicate.jsonContentType().canEncode(object, bodyType, template);
+ *   }
+ * }
  * 
+ * + *

{@link Encoder#encode(Object, Type, RequestTemplate) encode} remains the only abstract method, + * so this stays a functional interface and a bare lambda is an encoder that accepts everything. + * + *

Encoders that wrap another encoder should forward {@code canEncode} to their delegate, so that + * wrapping does not discard the delegate's applicability. + * + * @see MultiEncoder + * @see EncoderPredicate */ @Experimental -public class PredicatedEncoder implements Encoder { - - private final EncoderPredicate predicate; - - private final Encoder delegate; - - public PredicatedEncoder(EncoderPredicate predicate, Encoder delegate) { - this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null"); - this.delegate = Objects.requireNonNull(delegate, "delegate cannot be null"); - } - - /** Restricts the delegate to requests whose {@code Content-Type} header denotes JSON. */ - public static PredicatedEncoder forJsonContentType(Encoder delegate) { - return new PredicatedEncoder( - (object, bodyType, template) -> Util.isJsonContentType(template), delegate); - } - - /** Restricts the delegate to requests whose {@code Content-Type} header denotes XML. */ - public static PredicatedEncoder forXmlContentType(Encoder delegate) { - return new PredicatedEncoder( - (object, bodyType, template) -> Util.isXmlContentType(template), delegate); - } - - /** Restricts the delegate to requests carrying no body. */ - public static PredicatedEncoder forEmptyBody(Encoder delegate) { - return new PredicatedEncoder((object, bodyType, template) -> object == null, delegate); - } +@FunctionalInterface +public interface PredicatedEncoder extends Encoder { /** - * Whether the guarded encoder accepts this request. + * Whether this encoder can handle the request. Defaults to accepting everything. * * @param object what to encode as the request body - * @param bodyType the type the object should be encoded as + * @param bodyType the type the object should be encoded as. {@link Encoder#MAP_STRING_WILDCARD} + * indicates form encoding. * @param template the request template to populate - * @return {@code true} if the delegate should handle this request + * @return {@code true} if this encoder can encode the request, {@code false} otherwise */ - public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { - return predicate.test(object, bodyType, template); - } - - @Override - public void encode(Object object, Type bodyType, RequestTemplate template) - throws EncodeException { - if (!canEncode(object, bodyType, template)) { - throw new EncodeException( - "Predicate of " + this + " rejected the request, so " + delegate + " was not invoked"); - } - delegate.encode(object, bodyType, template); - } - - @Override - public String toString() { - return "PredicatedEncoder{predicate=" + predicate + ", delegate=" + delegate + '}'; + default boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return true; } } diff --git a/core/src/test/java/feign/codec/EncoderPredicateTest.java b/core/src/test/java/feign/codec/EncoderPredicateTest.java new file mode 100644 index 000000000..899085d9f --- /dev/null +++ b/core/src/test/java/feign/codec/EncoderPredicateTest.java @@ -0,0 +1,115 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.RequestTemplate; +import org.junit.jupiter.api.Test; + +class EncoderPredicateTest { + + private static RequestTemplate template(String contentType) { + RequestTemplate template = new RequestTemplate(); + if (contentType != null) { + template.header("Content-Type", contentType); + } + return template; + } + + private static boolean test(EncoderPredicate predicate, String contentType) { + return predicate.canEncode("body", String.class, template(contentType)); + } + + @Test + void jsonContentTypeMatchesJsonOnly() { + EncoderPredicate json = EncoderPredicate.jsonContentType(); + + assertThat(test(json, "application/json")).isTrue(); + assertThat(test(json, "application/json;charset=utf-8")).isTrue(); + assertThat(test(json, "application/vnd.github+json")).isTrue(); + assertThat(test(json, "text/json")).isTrue(); + assertThat(test(json, "application/xml")).isFalse(); + assertThat(test(json, null)).isFalse(); + } + + @Test + void xmlContentTypeMatchesXmlOnly() { + EncoderPredicate xml = EncoderPredicate.xmlContentType(); + + assertThat(test(xml, "application/xml")).isTrue(); + assertThat(test(xml, "text/xml")).isTrue(); + assertThat(test(xml, "application/soap+xml")).isTrue(); + assertThat(test(xml, "application/json")).isFalse(); + assertThat(test(xml, null)).isFalse(); + } + + @Test + void contentTypeMatchesExactMediaTypeIgnoringParameters() { + EncoderPredicate form = EncoderPredicate.contentType("application/x-www-form-urlencoded"); + + assertThat(test(form, "application/x-www-form-urlencoded")).isTrue(); + assertThat(test(form, "APPLICATION/X-WWW-FORM-URLENCODED")).isTrue(); + assertThat(test(form, "application/x-www-form-urlencoded;charset=utf-8")).isTrue(); + assertThat(test(form, "application/x-www-form-urlencoded-extra")).isFalse(); + assertThat(test(form, "application/json")).isFalse(); + } + + @Test + void headerNameIsMatchedCaseInsensitively() { + RequestTemplate template = new RequestTemplate(); + template.header("content-type", "application/json"); + + assertThat(EncoderPredicate.jsonContentType().canEncode("body", String.class, template)) + .isTrue(); + } + + @Test + void emptyBodyMatchesNullBodyOnly() { + EncoderPredicate empty = EncoderPredicate.emptyBody(); + + assertThat(empty.canEncode(null, String.class, template(null))).isTrue(); + assertThat(empty.canEncode("body", String.class, template(null))).isFalse(); + } + + @Test + void bodyTypeMatchesExactType() { + EncoderPredicate bytes = EncoderPredicate.bodyType(byte[].class); + + assertThat(bytes.canEncode(new byte[0], byte[].class, template(null))).isTrue(); + assertThat(bytes.canEncode("body", String.class, template(null))).isFalse(); + } + + @Test + void formEncodedMatchesTheFormBodyTypeMarker() { + EncoderPredicate form = EncoderPredicate.formEncoded(); + + assertThat(form.canEncode(null, Encoder.MAP_STRING_WILDCARD, template(null))).isTrue(); + assertThat(form.canEncode("body", String.class, template(null))).isFalse(); + } + + @Test + void combinators() { + EncoderPredicate json = EncoderPredicate.jsonContentType(); + EncoderPredicate xml = EncoderPredicate.xmlContentType(); + + assertThat(test(json.or(xml), "application/xml")).isTrue(); + assertThat(test(json.or(xml), "text/plain")).isFalse(); + assertThat(test(json.and(xml), "application/json")).isFalse(); + assertThat(test(json.negate(), "application/xml")).isTrue(); + assertThat(test(json.negate(), "application/json")).isFalse(); + } +} diff --git a/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java new file mode 100644 index 000000000..8432acaea --- /dev/null +++ b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java @@ -0,0 +1,170 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Capability; +import feign.Feign; +import feign.Headers; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import java.lang.reflect.Type; +import java.util.Collections; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** How {@link MultiEncoder} behaves when a {@link Capability} wraps the configured encoder. */ +class MultiEncoderCapabilityTest { + + interface MixedApi { + @RequestLine("POST /json") + @Headers("Content-Type: application/json") + void json(String body); + + @RequestLine("POST /xml") + @Headers("Content-Type: application/xml") + void xml(String body); + } + + static class TaggingEncoder implements Encoder { + private final String tag; + + TaggingEncoder(String tag) { + this.tag = tag; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + template.body(tag); + } + } + + /** A capability that wraps the encoder, the way the metrics modules do. */ + public static class CountingCapability implements Capability { + int wrapped; + int encodeCalls; + + @Override + public Encoder enrich(Encoder encoder) { + wrapped++; + return (object, bodyType, template) -> { + encodeCalls++; + encoder.encode(object, bodyType, template); + }; + } + } + + private static MixedApi target(Feign.Builder builder, AtomicReference captured) { + return builder + .client( + (request, options) -> { + captured.set(new String(request.body(), Util.UTF_8)); + return Response.builder() + .status(200) + .reason("OK") + .request(request) + .headers(Collections.emptyMap()) + .body("", Util.UTF_8) + .build(); + }) + .target(MixedApi.class, "http://localhost:1"); + } + + private static RequestTemplate template(String contentType) { + RequestTemplate template = new RequestTemplate(); + template.header("Content-Type", contentType); + return template; + } + + @Test + void capabilityWrapsTheCompositeAndRoutingStillWorks() { + CountingCapability capability = new CountingCapability(); + AtomicReference captured = new AtomicReference<>(); + + MixedApi api = + target( + Feign.builder() + .encoder( + MultiEncoder.builder(new TaggingEncoder("fallback")) + .add(EncoderPredicate.jsonContentType(), new TaggingEncoder("json")) + .add(EncoderPredicate.xmlContentType(), new TaggingEncoder("xml")) + .build()) + .addCapability(capability), + captured); + + api.json("{}"); + assertThat(captured.get()).isEqualTo("json"); + + api.xml(""); + assertThat(captured.get()).isEqualTo("xml"); + + // the capability sees the MultiEncoder as one encoder, not one per delegate + assertThat(capability.wrapped).isEqualTo(1); + assertThat(capability.encodeCalls).isEqualTo(2); + } + + /** + * A wrapper that does not forward {@code canEncode} claims every request, which is why the + * metrics modules' {@code MeteredEncoder} forwards it to its delegate. + */ + @Test + void wrappingWithoutForwardingCanEncodeErasesSelfDeclaration() { + PredicatedEncoder jsonOnly = + new PredicatedEncoder() { + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + template.body("json"); + } + }; + + PredicatedEncoder naive = jsonOnly::encode; + + PredicatedEncoder forwarding = + new PredicatedEncoder() { + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return jsonOnly.canEncode(object, bodyType, template); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + jsonOnly.encode(object, bodyType, template); + } + }; + + RequestTemplate naiveTemplate = template("application/xml"); + MultiEncoder.builder(new TaggingEncoder("fallback")) + .add(naive) + .build() + .encode("body", String.class, naiveTemplate); + assertThat(naiveTemplate.requestBody().asString()).isEqualTo("json"); + + RequestTemplate forwardedTemplate = template("application/xml"); + MultiEncoder.builder(new TaggingEncoder("fallback")) + .add(forwarding) + .build() + .encode("body", String.class, forwardedTemplate); + assertThat(forwardedTemplate.requestBody().asString()).isEqualTo("fallback"); + } +} diff --git a/core/src/test/java/feign/codec/MultiEncoderTest.java b/core/src/test/java/feign/codec/MultiEncoderTest.java index a02f53eed..c00021ad4 100644 --- a/core/src/test/java/feign/codec/MultiEncoderTest.java +++ b/core/src/test/java/feign/codec/MultiEncoderTest.java @@ -20,13 +20,13 @@ import feign.Request; import feign.RequestTemplate; +import feign.Util; import java.lang.reflect.Type; -import java.util.Arrays; -import java.util.Collections; import org.junit.jupiter.api.Test; class MultiEncoderTest { + /** A plain encoder, with no opinion about what it can handle. */ private static class RecordingEncoder implements Encoder { private final String body; boolean invoked; @@ -42,6 +42,20 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { } } + /** An encoder that declares its own applicability, the way feign-gson and friends now do. */ + private static class SelfDeclaringJsonEncoder extends RecordingEncoder + implements PredicatedEncoder { + + SelfDeclaringJsonEncoder() { + super("json"); + } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } + } + private static RequestTemplate templateWithContentType(String contentType) { RequestTemplate template = new RequestTemplate(); if (contentType != null) { @@ -51,45 +65,75 @@ private static RequestTemplate templateWithContentType(String contentType) { } @Test - void usesFirstDelegateWhosePredicateAccepts() { - RecordingEncoder json = new RecordingEncoder("json"); - RecordingEncoder xml = new RecordingEncoder("xml"); + void routesToTheEncoderThatDeclaresItCanHandleTheRequest() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = - MultiEncoder.of( - fallback, - PredicatedEncoder.forJsonContentType(json), - PredicatedEncoder.forXmlContentType(xml)); + Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); RequestTemplate template = templateWithContentType("application/json"); encoder.encode("body", String.class, template); assertThat(json.invoked).isTrue(); - assertThat(xml.invoked).isFalse(); assertThat(fallback.invoked).isFalse(); assertThat(template.requestBody().asString()).isEqualTo("json"); } + @Test + void pairsAPredicateWithAnEncoderThatDoesNotDeclareItself() { + RecordingEncoder xml = new RecordingEncoder("xml"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder(fallback).add(EncoderPredicate.xmlContentType(), xml).build(); + + encoder.encode("body", String.class, templateWithContentType("application/xml")); + + assertThat(xml.invoked).isTrue(); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void mixesSelfDeclaringEncodersAndPairs() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + RecordingEncoder xml = new RecordingEncoder("xml"); + RecordingEncoder binary = new RecordingEncoder("binary"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder(fallback) + .add(json) + .add(EncoderPredicate.xmlContentType(), xml) + .add(EncoderPredicate.bodyType(byte[].class), binary) + .build(); + + encoder.encode( + new byte[] {1}, byte[].class, templateWithContentType("application/octet-stream")); + + assertThat(binary.invoked).isTrue(); + assertThat(json.invoked).isFalse(); + assertThat(xml.invoked).isFalse(); + assertThat(fallback.invoked).isFalse(); + } + @Test void matchesSuffixedContentTypes() { - RecordingEncoder json = new RecordingEncoder("json"); + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); encoder.encode("body", String.class, templateWithContentType("application/vnd.github+json")); assertThat(json.invoked).isTrue(); - assertThat(fallback.invoked).isFalse(); } @Test - void fallsBackToDefaultEncoderWhenNoPredicateAccepts() { - RecordingEncoder json = new RecordingEncoder("json"); + void fallsBackWhenNoDelegateAccepts() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); RequestTemplate template = templateWithContentType("text/plain"); encoder.encode("body", String.class, template); @@ -100,29 +144,61 @@ void fallsBackToDefaultEncoderWhenNoPredicateAccepts() { } @Test - void fallsBackToDefaultEncoderWhenNoContentTypeIsSet() { - RecordingEncoder json = new RecordingEncoder("json"); + void fallsBackWhenNoContentTypeIsSet() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.of(fallback, PredicatedEncoder.forJsonContentType(json)); + Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); encoder.encode("body", String.class, templateWithContentType(null)); - assertThat(json.invoked).isFalse(); assertThat(fallback.invoked).isTrue(); } @Test - void withoutDelegatesEverythingGoesToTheDefaultEncoder() { + void withNoDelegatesEverythingGoesToTheDefaultEncoder() { RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.of(fallback); + Encoder encoder = MultiEncoder.builder(fallback).build(); encoder.encode("body", String.class, templateWithContentType("application/json")); assertThat(fallback.invoked).isTrue(); } + @Test + void delegatesAreConsultedInOrder() { + RecordingEncoder first = new RecordingEncoder("first"); + RecordingEncoder second = new RecordingEncoder("second"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder(fallback) + .add(EncoderPredicate.jsonContentType(), first) + .add(EncoderPredicate.jsonContentType(), second) + .build(); + + encoder.encode("body", String.class, templateWithContentType("application/json")); + + assertThat(first.invoked).isTrue(); + assertThat(second.invoked).isFalse(); + } + + @Test + void anEncoderWithoutAPredicateAcceptsEverything() { + // a bare lambda is a PredicatedEncoder whose default canEncode returns true + RecordingEncoder fallback = new RecordingEncoder("fallback"); + PredicatedEncoder greedy = (object, bodyType, template) -> template.body("greedy"); + + Encoder encoder = MultiEncoder.builder(fallback).add(greedy).build(); + + RequestTemplate template = templateWithContentType("text/plain"); + encoder.encode("body", String.class, template); + + assertThat(fallback.invoked).isFalse(); + assertThat(template.requestBody().asString()).isEqualTo("greedy"); + } + @Test void propagatesEncodeExceptionFromDelegate() { Encoder failing = @@ -131,7 +207,9 @@ void propagatesEncodeExceptionFromDelegate() { }; Encoder encoder = - MultiEncoder.of(new DefaultEncoder(), PredicatedEncoder.forJsonContentType(failing)); + MultiEncoder.builder(new DefaultEncoder()) + .add(EncoderPredicate.jsonContentType(), failing) + .build(); assertThatThrownBy( () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) @@ -140,39 +218,26 @@ void propagatesEncodeExceptionFromDelegate() { } @Test - void rejectsNullDefaultEncoder() { - assertThatThrownBy(() -> MultiEncoder.of(null)) + void rejectsNullArguments() { + assertThatThrownBy(() -> MultiEncoder.builder(null)) .isInstanceOf(NullPointerException.class) .hasMessage("defaultEncoder cannot be null"); - } - - @Test - void rejectsNullDelegate() { - assertThatThrownBy(() -> MultiEncoder.of(new DefaultEncoder(), Collections.singletonList(null))) + assertThatThrownBy(() -> MultiEncoder.builder(new DefaultEncoder()).add(null)) .isInstanceOf(NullPointerException.class) - .hasMessage("delegates cannot contain null"); + .hasMessage("encoder cannot be null"); + assertThatThrownBy( + () -> MultiEncoder.builder(new DefaultEncoder()).add(null, new DefaultEncoder())) + .isInstanceOf(NullPointerException.class) + .hasMessage("predicate cannot be null"); } @Test void toStringDescribesDelegates() { Encoder encoder = - MultiEncoder.of( - new DefaultEncoder(), PredicatedEncoder.forJsonContentType(new DefaultEncoder())); + MultiEncoder.builder(new DefaultEncoder()) + .add(EncoderPredicate.jsonContentType(), new RecordingEncoder("json")) + .build(); assertThat(encoder.toString()).startsWith("MultiEncoder{defaultEncoder="); - assertThat(encoder.toString()).contains("PredicatedEncoder{"); - } - - @Test - void listFactoryIsEquivalentToVarargs() { - RecordingEncoder json = new RecordingEncoder("json"); - RecordingEncoder fallback = new RecordingEncoder("fallback"); - - Encoder encoder = - MultiEncoder.of(fallback, Arrays.asList(PredicatedEncoder.forJsonContentType(json))); - - encoder.encode("body", String.class, templateWithContentType("application/json")); - - assertThat(json.invoked).isTrue(); } } diff --git a/core/src/test/java/feign/codec/PredicatedEncoderTest.java b/core/src/test/java/feign/codec/PredicatedEncoderTest.java deleted file mode 100644 index 69a90b08d..000000000 --- a/core/src/test/java/feign/codec/PredicatedEncoderTest.java +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package feign.codec; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import feign.Request; -import feign.RequestTemplate; -import java.lang.reflect.Type; -import org.junit.jupiter.api.Test; - -class PredicatedEncoderTest { - - private static class RecordingEncoder implements Encoder { - boolean invoked; - - @Override - public void encode(Object object, Type bodyType, RequestTemplate template) { - invoked = true; - template.body(Request.Body.create("encoded")); - } - } - - private static RequestTemplate templateWithContentType(String contentType) { - RequestTemplate template = new RequestTemplate(); - if (contentType != null) { - template.header("Content-Type", contentType); - } - return template; - } - - @Test - void delegatesWhenPredicateAccepts() { - RecordingEncoder delegate = new RecordingEncoder(); - PredicatedEncoder encoder = new PredicatedEncoder((o, t, tpl) -> true, delegate); - - RequestTemplate template = templateWithContentType(null); - encoder.encode("body", String.class, template); - - assertThat(delegate.invoked).isTrue(); - assertThat(template.requestBody().asString()).isEqualTo("encoded"); - } - - @Test - void throwsAndSkipsDelegateWhenPredicateRejects() { - RecordingEncoder delegate = new RecordingEncoder(); - PredicatedEncoder encoder = new PredicatedEncoder((o, t, tpl) -> false, delegate); - - assertThatThrownBy(() -> encoder.encode("body", String.class, templateWithContentType(null))) - .isInstanceOf(EncodeException.class); - - assertThat(delegate.invoked).isFalse(); - } - - @Test - void canEncodeReflectsThePredicate() { - PredicatedEncoder encoder = - new PredicatedEncoder((o, t, tpl) -> "yes".equals(o), new RecordingEncoder()); - - assertThat(encoder.canEncode("yes", String.class, templateWithContentType(null))).isTrue(); - assertThat(encoder.canEncode("no", String.class, templateWithContentType(null))).isFalse(); - } - - @Test - void forJsonContentTypeMatchesJsonOnly() { - PredicatedEncoder encoder = PredicatedEncoder.forJsonContentType(new RecordingEncoder()); - - assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/json"))) - .isTrue(); - assertThat( - encoder.canEncode( - null, String.class, templateWithContentType("application/json;charset=utf-8"))) - .isTrue(); - assertThat( - encoder.canEncode( - null, String.class, templateWithContentType("application/vnd.github+json"))) - .isTrue(); - assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/xml"))) - .isFalse(); - assertThat(encoder.canEncode(null, String.class, templateWithContentType(null))).isFalse(); - } - - @Test - void forXmlContentTypeMatchesXmlOnly() { - PredicatedEncoder encoder = PredicatedEncoder.forXmlContentType(new RecordingEncoder()); - - assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/xml"))) - .isTrue(); - assertThat(encoder.canEncode(null, String.class, templateWithContentType("text/xml"))).isTrue(); - assertThat( - encoder.canEncode(null, String.class, templateWithContentType("application/soap+xml"))) - .isTrue(); - assertThat(encoder.canEncode(null, String.class, templateWithContentType("application/json"))) - .isFalse(); - } - - @Test - void contentTypeHeaderNameIsMatchedCaseInsensitively() { - PredicatedEncoder encoder = PredicatedEncoder.forJsonContentType(new RecordingEncoder()); - - RequestTemplate template = new RequestTemplate(); - template.header("content-type", "application/json"); - - assertThat(encoder.canEncode(null, String.class, template)).isTrue(); - } - - @Test - void forEmptyBodyMatchesNullBodyOnly() { - PredicatedEncoder encoder = PredicatedEncoder.forEmptyBody(new RecordingEncoder()); - - assertThat(encoder.canEncode(null, String.class, templateWithContentType(null))).isTrue(); - assertThat(encoder.canEncode("body", String.class, templateWithContentType(null))).isFalse(); - } - - @Test - void rejectsNullConstructorArguments() { - assertThatThrownBy(() -> new PredicatedEncoder(null, new RecordingEncoder())) - .isInstanceOf(NullPointerException.class) - .hasMessage("predicate cannot be null"); - assertThatThrownBy(() -> new PredicatedEncoder((o, t, tpl) -> true, null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("delegate cannot be null"); - } -} diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java index 2a2c644c5..f5eb123c4 100644 --- a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java +++ b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java @@ -20,10 +20,11 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; /** Warp feign {@link Encoder} with metrics. */ -public class MeteredEncoder implements Encoder { +public class MeteredEncoder implements Encoder, PredicatedEncoder { private final Encoder encoder; private final MetricRegistry metricRegistry; @@ -59,4 +60,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template) .update(template.body().length); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return !(encoder instanceof PredicatedEncoder) + || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template); + } } diff --git a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java index 77cc7b78c..2cff8cd78 100644 --- a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java +++ b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java @@ -18,13 +18,14 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import io.dropwizard.metrics5.MetricRegistry; import io.dropwizard.metrics5.Timer.Context; import java.lang.reflect.Type; import java.util.Map; /** Warp feign {@link Encoder} with metrics. */ -public class MeteredEncoder implements Encoder { +public class MeteredEncoder implements Encoder, PredicatedEncoder { private final Encoder encoder; private final MetricRegistry metricRegistry; @@ -71,4 +72,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template) .update(template.body().length); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return !(encoder instanceof PredicatedEncoder) + || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template); + } } diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java index 06a98e8dd..35efae25b 100644 --- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java @@ -22,12 +22,13 @@ import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.JsonEncoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; /** * @author changjin wei(魏昌进) */ -public class Fastjson2Encoder implements Encoder, JsonEncoder { +public class Fastjson2Encoder implements Encoder, PredicatedEncoder, JsonEncoder { private final JSONWriter.Feature[] features; @@ -44,4 +45,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { template.body(JSON.toJSONBytes(object, features), Util.UTF_8); } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java index c4484bc6e..1056d5f9c 100644 --- a/gson/src/main/java/feign/gson/GsonEncoder.java +++ b/gson/src/main/java/feign/gson/GsonEncoder.java @@ -18,12 +18,14 @@ import com.google.gson.Gson; import com.google.gson.TypeAdapter; import feign.RequestTemplate; +import feign.Util; import feign.codec.Encoder; import feign.codec.JsonEncoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; import java.util.Collections; -public class GsonEncoder implements Encoder, JsonEncoder { +public class GsonEncoder implements Encoder, PredicatedEncoder, JsonEncoder { private final Gson gson; @@ -43,4 +45,9 @@ public GsonEncoder(Gson gson) { public void encode(Object object, Type bodyType, RequestTemplate template) { template.body(gson.toJson(object, bodyType)); } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java index 3786edb36..67f42eda5 100644 --- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java @@ -21,14 +21,16 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.lang.reflect.Type; import java.nio.charset.Charset; -public final class JacksonJaxbJsonEncoder implements Encoder { +public final class JacksonJaxbJsonEncoder implements Encoder, PredicatedEncoder { private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider; public JacksonJaxbJsonEncoder() { @@ -51,4 +53,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) throw new EncodeException(e.getMessage(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java index 44118eed7..e1ee4cfd4 100644 --- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java +++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java @@ -18,13 +18,15 @@ import com.fasterxml.jackson.jr.ob.JSON; import com.fasterxml.jackson.jr.ob.JacksonJrExtension; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import java.io.IOException; import java.lang.reflect.Type; /** A {@link Encoder} that uses Jackson Jr to convert objects to String or byte representation. */ -public class JacksonJrEncoder extends JacksonJrMapper implements Encoder { +public class JacksonJrEncoder extends JacksonJrMapper implements Encoder, PredicatedEncoder { public JacksonJrEncoder() { super(); @@ -61,4 +63,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { throw new EncodeException(e.getMessage(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java index 48b169a8d..1c1a9d83e 100644 --- a/jackson/src/main/java/feign/jackson/JacksonEncoder.java +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -26,10 +26,11 @@ import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.JsonEncoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; import java.util.Collections; -public class JacksonEncoder implements Encoder, JsonEncoder { +public class JacksonEncoder implements Encoder, PredicatedEncoder, JsonEncoder { private final ObjectMapper mapper; @@ -58,4 +59,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { throw new EncodeException(e.getMessage(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java index 90342c016..41a5ab589 100644 --- a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java @@ -21,6 +21,7 @@ import feign.codec.EncodeException; import feign.codec.Encoder; import feign.codec.JsonEncoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; import java.util.Collections; import tools.jackson.core.JacksonException; @@ -29,7 +30,7 @@ import tools.jackson.databind.SerializationFeature; import tools.jackson.databind.json.JsonMapper; -public class Jackson3Encoder implements Encoder, JsonEncoder { +public class Jackson3Encoder implements Encoder, PredicatedEncoder, JsonEncoder { private final JsonMapper mapper; @@ -60,4 +61,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { throw new EncodeException(e.getMessage(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java index 4ea3b7e99..b5eed6dc3 100644 --- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java @@ -16,8 +16,10 @@ package feign.jaxb; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; import java.io.StringWriter; @@ -42,7 +44,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBEncoder implements Encoder { +public class JAXBEncoder implements Encoder, PredicatedEncoder { private final JAXBContextFactory jaxbContextFactory; @@ -65,4 +67,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { throw new EncodeException(e.toString(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isXmlContentType(template); + } } diff --git a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java index aae439cae..ace9b148c 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -16,8 +16,10 @@ package feign.jaxb; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import java.io.StringWriter; import java.lang.reflect.Type; import javax.xml.bind.JAXBException; @@ -42,7 +44,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBEncoder implements Encoder { +public class JAXBEncoder implements Encoder, PredicatedEncoder { private final JAXBContextFactory jaxbContextFactory; @@ -65,4 +67,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { throw new EncodeException(e.toString(), e); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isXmlContentType(template); + } } diff --git a/json/src/main/java/feign/json/JsonEncoder.java b/json/src/main/java/feign/json/JsonEncoder.java index 655bb7594..9e0b3a078 100644 --- a/json/src/main/java/feign/json/JsonEncoder.java +++ b/json/src/main/java/feign/json/JsonEncoder.java @@ -18,8 +18,10 @@ import static java.lang.String.format; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; import org.json.JSONArray; import org.json.JSONObject; @@ -51,7 +53,7 @@ * github.create("openfeign", "feign", contributor); * */ -public class JsonEncoder implements Encoder { +public class JsonEncoder implements Encoder, PredicatedEncoder { @Override public void encode(Object object, Type bodyType, RequestTemplate template) @@ -63,4 +65,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) throw new EncodeException(format("%s is not a type supported by this encoder.", bodyType)); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java index 2fb73d12f..197c2721f 100644 --- a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java +++ b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java @@ -20,11 +20,12 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import io.micrometer.core.instrument.*; import java.lang.reflect.Type; /** Wrap feign {@link Encoder} with metrics. */ -public class MeteredEncoder implements Encoder { +public class MeteredEncoder implements Encoder, PredicatedEncoder { private final Encoder encoder; private final MeterRegistry meterRegistry; @@ -79,4 +80,10 @@ protected DistributionSummary createSummary( protected Tag[] extraTags(Object object, Type bodyType, RequestTemplate template) { return EMPTY_TAGS_ARRAY; } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return !(encoder instanceof PredicatedEncoder) + || ((PredicatedEncoder) encoder).canEncode(object, bodyType, template); + } } diff --git a/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java index b65f705e2..1e7283cef 100644 --- a/moshi/src/main/java/feign/moshi/MoshiEncoder.java +++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java @@ -18,11 +18,13 @@ import com.squareup.moshi.JsonAdapter; import com.squareup.moshi.Moshi; import feign.RequestTemplate; +import feign.Util; import feign.codec.Encoder; import feign.codec.JsonEncoder; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; -public class MoshiEncoder implements Encoder, JsonEncoder { +public class MoshiEncoder implements Encoder, PredicatedEncoder, JsonEncoder { private final Moshi moshi; @@ -43,4 +45,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { JsonAdapter jsonAdapter = moshi.adapter(bodyType).indent(" "); template.body(jsonAdapter.toJson(object)); } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java index 2b4a59cab..860ab67aa 100644 --- a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java @@ -16,8 +16,10 @@ package feign.soap; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import feign.jaxb.JAXBContextFactory; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Marshaller; @@ -78,7 +80,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class SOAPEncoder implements Encoder { +public class SOAPEncoder implements Encoder, PredicatedEncoder { private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL; @@ -220,4 +222,9 @@ public SOAPEncoder build() { return new SOAPEncoder(this); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isXmlContentType(template); + } } diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java index d22d97fef..a9bbe81e0 100644 --- a/soap/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap/src/main/java/feign/soap/SOAPEncoder.java @@ -16,8 +16,10 @@ package feign.soap; import feign.RequestTemplate; +import feign.Util; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import feign.jaxb.JAXBContextFactory; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -82,7 +84,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class SOAPEncoder implements Encoder { +public class SOAPEncoder implements Encoder, PredicatedEncoder { private static final String DEFAULT_SOAP_PROTOCOL = SOAPConstants.SOAP_1_1_PROTOCOL; @@ -224,4 +226,9 @@ public SOAPEncoder build() { return new SOAPEncoder(this); } } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isXmlContentType(template); + } } diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index 50dd352f2..c1396675a 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -1,64 +1,64 @@ -@startmindmap -* Feign -** clients -*** java.net.URL -*** Apache HTTP -*** Apache HC5 -*** Google HTTP -*** Java 11 Http2 -*** OK Http -*** Ribbon -** async clients -*** java.net.URL -*** Apache HC5 -*** OkHttp -*** Vertx -*** Reactive Wrappers -** contracts -*** Feign -*** JAX-RS -*** JAX-RS 2 -*** JAX-RS 3 / Jakarta -*** JAX-RS 4 -*** Spring -*** SOAP -*** SOAP Jakarta -*** Spring boot (3rd party) -** language -*** Kotlin -*** GraphQL - -left side - -** encoders/decoders -*** Multi encoder (predicate based, experimental) -*** GSON -*** JAXB -*** JAXB Jakarta -*** Jackson -*** Jackson 3 -*** Jackson JAXB -*** Jackson Jr -*** Sax -*** JSON-java -*** Moshi -*** Fastjson2 -*** Form -*** Form Spring -** metrics -*** Dropwizard Metrics 4 -*** Dropwizard Metrics 5 -*** Micrometer -** interceptors -*** RequestInterceptor -*** ResponseInterceptor -*** MethodInterceptor -**** Bean Validation (JSR-303) -**** Bean Validation (Jakarta) -**** HTTP Cache (ETag / Last-Modified) -** extras -*** Hystrix -*** SLF4J -*** Mock -*** Annotation Error Decoder -@endmindmap +@startmindmap +* Feign +** clients +*** java.net.URL +*** Apache HTTP +*** Apache HC5 +*** Google HTTP +*** Java 11 Http2 +*** OK Http +*** Ribbon +** async clients +*** java.net.URL +*** Apache HC5 +*** OkHttp +*** Vertx +*** Reactive Wrappers +** contracts +*** Feign +*** JAX-RS +*** JAX-RS 2 +*** JAX-RS 3 / Jakarta +*** JAX-RS 4 +*** Spring +*** SOAP +*** SOAP Jakarta +*** Spring boot (3rd party) +** language +*** Kotlin +*** GraphQL + +left side + +** encoders/decoders +*** Multi encoder (predicate based, experimental) +*** GSON +*** JAXB +*** JAXB Jakarta +*** Jackson +*** Jackson 3 +*** Jackson JAXB +*** Jackson Jr +*** Sax +*** JSON-java +*** Moshi +*** Fastjson2 +*** Form +*** Form Spring +** metrics +*** Dropwizard Metrics 4 +*** Dropwizard Metrics 5 +*** Micrometer +** interceptors +*** RequestInterceptor +*** ResponseInterceptor +*** MethodInterceptor +**** Bean Validation (JSR-303) +**** Bean Validation (Jakarta) +**** HTTP Cache (ETag / Last-Modified) +** extras +*** Hystrix +*** SLF4J +*** Mock +*** Annotation Error Decoder +@endmindmap From d2fa88a973a9f0caa4a74aa8f2ef49144669960c Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:27:53 -0300 Subject: [PATCH 34/45] Add Util helpers for detecting JSON and XML response content types Signed-off-by: Marvin Froeder --- core/src/main/java/feign/Util.java | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/core/src/main/java/feign/Util.java b/core/src/main/java/feign/Util.java index 91cb7e5a1..43a403151 100644 --- a/core/src/main/java/feign/Util.java +++ b/core/src/main/java/feign/Util.java @@ -51,6 +51,7 @@ import java.util.TreeMap; import java.util.function.Predicate; import java.util.function.Supplier; +import java.util.regex.Pattern; import java.util.stream.Stream; /** Utilities, typically copied in from guava, so as to avoid dependency conflicts. */ @@ -59,6 +60,9 @@ public class Util { /** The HTTP Content-Length header field name. */ public static final String CONTENT_LENGTH = "Content-Length"; + /** The HTTP Content-Type header field name. */ + public static final String CONTENT_TYPE = "Content-Type"; + /** The HTTP Content-Encoding header field name. */ public static final String CONTENT_ENCODING = "Content-Encoding"; @@ -83,6 +87,15 @@ public class Util { private static final int BUF_SIZE = 0x800; // 2K chars (4K bytes) + // matches application/json, text/json, application/vnd.github+json, + // application/json;charset=utf-8 + private static final Pattern JSON_CONTENT_TYPE = + Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?json.*"); + + // matches application/xml, text/xml, application/soap+xml, application/xml;charset=utf-8 + private static final Pattern XML_CONTENT_TYPE = + Pattern.compile("(?i)\\w+/(?:[\\w._-]+\\+)?xml.*"); + /** Type literal for {@code Map}. */ public static final Type MAP_STRING_WILDCARD = new Types.ParameterizedTypeImpl( @@ -371,4 +384,69 @@ public static String getThreadIdentifier() { + "_" + currentThread.getId(); } + + /** + * Checks whether the {@code Content-Type} header of the given response denotes JSON. + * + *

Matches {@code application/json} as well as suffixed types such as {@code + * application/vnd.github+json}. The header name is matched case-insensitively. + * + * @param response the response to check + * @return {@code true} if the content type is JSON, {@code false} otherwise + */ + @Experimental + public static boolean isJsonContentType(Response response) { + return hasContentTypeMatching(response, JSON_CONTENT_TYPE); + } + + /** + * Checks whether the {@code Content-Type} header of the given response denotes XML. + * + *

Matches {@code application/xml} and {@code text/xml} as well as suffixed types such as + * {@code application/soap+xml}. The header name is matched case-insensitively. + * + * @param response the response to check + * @return {@code true} if the content type is XML, {@code false} otherwise + */ + @Experimental + public static boolean isXmlContentType(Response response) { + return hasContentTypeMatching(response, XML_CONTENT_TYPE); + } + + /** + * Checks whether the {@code Content-Type} header of the given response starts with the given + * media type, ignoring case and any parameters such as {@code ;charset=utf-8}. + * + * @param response the response to check + * @param mediaType the media type to look for, for example {@code text/csv} + * @return {@code true} if the content type matches, {@code false} otherwise + */ + @Experimental + public static boolean hasContentType(Response response, String mediaType) { + return contentTypes(response) + .anyMatch( + contentType -> { + String trimmed = contentType.trim(); + return trimmed.regionMatches(true, 0, mediaType, 0, mediaType.length()) + && (trimmed.length() == mediaType.length() + || trimmed.charAt(mediaType.length()) == ';'); + }); + } + + private static Stream contentTypes(Response response) { + if (response == null || response.headers() == null) { + return Stream.empty(); + } + return response.headers().entrySet().stream() + .filter(header -> CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .filter(Objects::nonNull); + } + + private static boolean hasContentTypeMatching(Response response, Pattern pattern) { + return contentTypes(response) + .anyMatch(contentType -> pattern.matcher(contentType.trim()).matches()); + } } From 42b1573e08f379c8f07dc85b50a215315fc5765d Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:28:09 -0300 Subject: [PATCH 35/45] Add PredicatedDecoder and DecoderPredicate for conditional decoding Signed-off-by: Marvin Froeder --- .../java/feign/codec/DecoderPredicate.java | 105 ++++++++++++++ .../java/feign/codec/PredicatedDecoder.java | 65 +++++++++ .../feign/codec/DecoderPredicateTest.java | 130 ++++++++++++++++++ 3 files changed, 300 insertions(+) create mode 100644 core/src/main/java/feign/codec/DecoderPredicate.java create mode 100644 core/src/main/java/feign/codec/PredicatedDecoder.java create mode 100644 core/src/test/java/feign/codec/DecoderPredicateTest.java diff --git a/core/src/main/java/feign/codec/DecoderPredicate.java b/core/src/main/java/feign/codec/DecoderPredicate.java new file mode 100644 index 000000000..0727c0ebe --- /dev/null +++ b/core/src/main/java/feign/codec/DecoderPredicate.java @@ -0,0 +1,105 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.Experimental; +import feign.Response; +import feign.Util; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Objects; + +/** + * Decides whether a response can be handled by a {@link Decoder}. + * + *

Predicates receive the same two arguments as {@link Decoder#decode(Response, Type)}, so they + * can discriminate on the response status, on anything in its headers such as the {@code + * Content-Type}, or on the type the caller expects back. + * + *

Predicates must not read the response body. The body is a single-pass stream + * for most clients, so consuming it here would leave nothing for the decoder that is eventually + * chosen. + * + * @see PredicatedDecoder + * @see MultiDecoder + */ +@Experimental +@FunctionalInterface +public interface DecoderPredicate { + + /** + * Whether the decoder this predicate guards can handle the response. + * + * @param response the response that would be decoded. Its body must not be read. + * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the + * caller expects back + * @return {@code true} if the response can be decoded, {@code false} otherwise + */ + boolean canDecode(Response response, Type type); + + /** Matches responses whose {@code Content-Type} header denotes JSON. */ + static DecoderPredicate jsonContentType() { + return (response, type) -> Util.isJsonContentType(response); + } + + /** Matches responses whose {@code Content-Type} header denotes XML. */ + static DecoderPredicate xmlContentType() { + return (response, type) -> Util.isXmlContentType(response); + } + + /** + * Matches responses whose {@code Content-Type} header starts with the given media type, ignoring + * case and any parameters such as {@code ;charset=utf-8}. + */ + static DecoderPredicate contentType(String mediaType) { + Objects.requireNonNull(mediaType, "mediaType cannot be null"); + return (response, type) -> Util.hasContentType(response, mediaType); + } + + /** Matches responses carrying no body, such as a {@code 204 No Content}. */ + static DecoderPredicate emptyBody() { + return (response, type) -> + response.body() == null + || (response.body().length() != null && response.body().length() == 0); + } + + /** Matches responses whose status is one of the given codes. */ + static DecoderPredicate status(int... statuses) { + int[] accepted = Arrays.copyOf(statuses, statuses.length); + Arrays.sort(accepted); + return (response, type) -> Arrays.binarySearch(accepted, response.status()) >= 0; + } + + /** Matches responses the caller expects to come back as exactly the given type. */ + static DecoderPredicate returnType(Type expected) { + Objects.requireNonNull(expected, "expected cannot be null"); + return (response, type) -> expected.equals(type); + } + + default DecoderPredicate and(DecoderPredicate other) { + Objects.requireNonNull(other, "other cannot be null"); + return (response, type) -> canDecode(response, type) && other.canDecode(response, type); + } + + default DecoderPredicate or(DecoderPredicate other) { + Objects.requireNonNull(other, "other cannot be null"); + return (response, type) -> canDecode(response, type) || other.canDecode(response, type); + } + + default DecoderPredicate negate() { + return (response, type) -> !canDecode(response, type); + } +} diff --git a/core/src/main/java/feign/codec/PredicatedDecoder.java b/core/src/main/java/feign/codec/PredicatedDecoder.java new file mode 100644 index 000000000..9d6bf5067 --- /dev/null +++ b/core/src/main/java/feign/codec/PredicatedDecoder.java @@ -0,0 +1,65 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.Experimental; +import feign.Response; +import java.lang.reflect.Type; + +/** + * A {@link Decoder} that knows which responses it can handle. + * + *

Decoders implement this to declare their own applicability, so a {@link MultiDecoder} can + * route each response to the right one without the call site having to wrap anything: + * + *

+ * public class JacksonDecoder implements Decoder, PredicatedDecoder {
+ *
+ *   @Override
+ *   public boolean canDecode(Response response, Type type) {
+ *     return Util.isJsonContentType(response);
+ *   }
+ * }
+ * 
+ * + *

{@link Decoder#decode(Response, Type) decode} remains the only abstract method, so this stays + * a functional interface and a bare lambda is a decoder that accepts everything. + * + *

Decoders that wrap another decoder should forward {@code canDecode} to their delegate, so that + * wrapping does not discard the delegate's applicability. + * + * @see MultiDecoder + * @see DecoderPredicate + */ +@Experimental +@FunctionalInterface +public interface PredicatedDecoder extends Decoder { + + /** + * Whether this decoder can handle the response. Defaults to accepting everything. + * + *

The response body must not be read here: it is a single-pass stream for most clients, so + * consuming it would leave nothing for the decoder that is eventually chosen. + * + * @param response the response that would be decoded. Its body must not be read. + * @param type the {@link java.lang.reflect.Method#getGenericReturnType() generic return type} the + * caller expects back + * @return {@code true} if this decoder can decode the response, {@code false} otherwise + */ + default boolean canDecode(Response response, Type type) { + return true; + } +} diff --git a/core/src/test/java/feign/codec/DecoderPredicateTest.java b/core/src/test/java/feign/codec/DecoderPredicateTest.java new file mode 100644 index 000000000..a99645a46 --- /dev/null +++ b/core/src/test/java/feign/codec/DecoderPredicateTest.java @@ -0,0 +1,130 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DecoderPredicateTest { + + private static Response response(String contentType) { + return response(contentType, 200, "body"); + } + + private static Response response(String contentType, int status, String body) { + Map> headers = new HashMap<>(); + if (contentType != null) { + headers.put("Content-Type", Collections.singletonList(contentType)); + } + Response.Builder builder = + Response.builder() + .status(status) + .reason("OK") + .headers(headers) + .request( + Request.create( + HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8, null)); + if (body != null) { + builder.body(body, Util.UTF_8); + } + return builder.build(); + } + + @Test + void jsonContentTypeMatchesPlainAndSuffixedTypes() { + DecoderPredicate predicate = DecoderPredicate.jsonContentType(); + + assertThat(predicate.canDecode(response("application/json"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json;charset=utf-8"), String.class)) + .isTrue(); + assertThat(predicate.canDecode(response("APPLICATION/JSON"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("text/json"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/vnd.github+json"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/xml"), String.class)).isFalse(); + assertThat(predicate.canDecode(response("application/x-json-stream"), String.class)).isFalse(); + assertThat(predicate.canDecode(response(null), String.class)).isFalse(); + } + + @Test + void xmlContentTypeMatchesPlainAndSuffixedTypes() { + DecoderPredicate predicate = DecoderPredicate.xmlContentType(); + + assertThat(predicate.canDecode(response("application/xml"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("text/xml;charset=utf-8"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/soap+xml"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json"), String.class)).isFalse(); + assertThat(predicate.canDecode(response(null), String.class)).isFalse(); + } + + @Test + void contentTypeIgnoresCaseAndParameters() { + DecoderPredicate predicate = DecoderPredicate.contentType("text/csv"); + + assertThat(predicate.canDecode(response("text/csv"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("TEXT/CSV;charset=utf-8"), String.class)).isTrue(); + assertThat(predicate.canDecode(response("text/csv-x"), String.class)).isFalse(); + assertThat(predicate.canDecode(response("text/plain"), String.class)).isFalse(); + } + + @Test + void emptyBodyMatchesResponsesWithoutContent() { + DecoderPredicate predicate = DecoderPredicate.emptyBody(); + + assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json", 200, ""), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class)) + .isFalse(); + } + + @Test + void statusMatchesTheGivenCodes() { + DecoderPredicate predicate = DecoderPredicate.status(204, 404); + + assertThat(predicate.canDecode(response("application/json", 204, null), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json", 404, null), String.class)).isTrue(); + assertThat(predicate.canDecode(response("application/json", 200, "body"), String.class)) + .isFalse(); + } + + @Test + void returnTypeMatchesTheExpectedType() { + DecoderPredicate predicate = DecoderPredicate.returnType(byte[].class); + + assertThat(predicate.canDecode(response("application/octet-stream"), byte[].class)).isTrue(); + assertThat(predicate.canDecode(response("application/octet-stream"), String.class)).isFalse(); + } + + @Test + void combinesPredicates() { + DecoderPredicate json = DecoderPredicate.jsonContentType(); + DecoderPredicate ok = DecoderPredicate.status(200); + + assertThat(json.and(ok).canDecode(response("application/json"), String.class)).isTrue(); + assertThat(json.and(ok).canDecode(response("application/json", 204, null), String.class)) + .isFalse(); + assertThat(json.or(ok).canDecode(response("text/plain"), String.class)).isTrue(); + assertThat(json.negate().canDecode(response("text/plain"), String.class)).isTrue(); + } +} From 4411979a6af66ad377a725ed7466d1ef62e9b10c Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:28:17 -0300 Subject: [PATCH 36/45] Add MultiDecoder to select a decoder per response Signed-off-by: Marvin Froeder --- .../main/java/feign/codec/MultiDecoder.java | 156 +++++++++++ .../java/feign/codec/MultiDecoderTest.java | 260 ++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 core/src/main/java/feign/codec/MultiDecoder.java create mode 100644 core/src/test/java/feign/codec/MultiDecoderTest.java diff --git a/core/src/main/java/feign/codec/MultiDecoder.java b/core/src/main/java/feign/codec/MultiDecoder.java new file mode 100644 index 000000000..e89521023 --- /dev/null +++ b/core/src/main/java/feign/codec/MultiDecoder.java @@ -0,0 +1,156 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.Experimental; +import feign.FeignException; +import feign.Response; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * A {@link Decoder} that selects a delegate per response, falling back to a default decoder when no + * delegate accepts it. + * + *

Delegates come from two places. A decoder that implements {@link PredicatedDecoder} declares + * its own applicability and can simply be added; any other decoder is paired with a {@link + * DecoderPredicate} at the call site: + * + *

+ * Feign.builder()
+ *     .decoder(
+ *         MultiDecoder.builder(new DefaultDecoder())
+ *             .add(new JacksonDecoder())
+ *             .add(DecoderPredicate.xmlContentType(), new JAXBDecoder())
+ *             .add((response, type) -> type == byte[].class, new BinaryDecoder())
+ *             .build());
+ * 
+ * + *

Delegates are consulted in the order they were added, so the narrowest predicate should come + * first. The default decoder is consulted last. + * + * @see PredicatedDecoder + * @see DecoderPredicate + */ +@Experimental +public class MultiDecoder implements Decoder { + + private final Decoder defaultDecoder; + + private final List delegates; + + private MultiDecoder(Decoder defaultDecoder, List delegates) { + this.defaultDecoder = defaultDecoder; + this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); + } + + /** + * Starts building a multi-decoder. + * + * @param defaultDecoder the decoder used when no delegate accepts the response + * @return the builder + */ + public static Builder builder(Decoder defaultDecoder) { + return new Builder(defaultDecoder); + } + + /** + * Decodes using the first delegate that accepts the response, or the default decoder if none do. + * + * @param response {@inheritDoc} + * @param type {@inheritDoc} + * @return {@inheritDoc} + * @throws IOException {@inheritDoc} + * @throws DecodeException {@inheritDoc} + * @throws FeignException {@inheritDoc} + */ + @Override + public Object decode(Response response, Type type) + throws IOException, DecodeException, FeignException { + for (Delegate delegate : delegates) { + if (delegate.predicate.canDecode(response, type)) { + return delegate.decoder.decode(response, type); + } + } + return defaultDecoder.decode(response, type); + } + + @Override + public String toString() { + return "MultiDecoder{defaultDecoder=" + defaultDecoder + ", delegates=" + delegates + '}'; + } + + private static final class Delegate { + private final DecoderPredicate predicate; + private final Decoder decoder; + + Delegate(DecoderPredicate predicate, Decoder decoder) { + this.predicate = predicate; + this.decoder = decoder; + } + + @Override + public String toString() { + return decoder.toString(); + } + } + + /** Collects the delegates of a {@link MultiDecoder}. */ + @Experimental + public static final class Builder { + + private final Decoder defaultDecoder; + + private final List delegates = new ArrayList<>(); + + private Builder(Decoder defaultDecoder) { + this.defaultDecoder = Objects.requireNonNull(defaultDecoder, "defaultDecoder cannot be null"); + } + + /** + * Adds a decoder that declares its own applicability. + * + * @param decoder the decoder, consulted via {@link PredicatedDecoder#canDecode} + */ + public Builder add(PredicatedDecoder decoder) { + Objects.requireNonNull(decoder, "decoder cannot be null"); + return add(decoder::canDecode, decoder); + } + + /** + * Adds any decoder, guarded by the given predicate. Use this for decoders that do not implement + * {@link PredicatedDecoder}, including ones you do not control. + * + * @param predicate decides whether the decoder handles a response + * @param decoder the decoder to delegate to + */ + public Builder add(DecoderPredicate predicate, Decoder decoder) { + Objects.requireNonNull(predicate, "predicate cannot be null"); + Objects.requireNonNull(decoder, "decoder cannot be null"); + delegates.add(new Delegate(predicate, decoder)); + return this; + } + + /** Builds the multi-decoder. */ + public MultiDecoder build() { + return new MultiDecoder(defaultDecoder, delegates); + } + } +} diff --git a/core/src/test/java/feign/codec/MultiDecoderTest.java b/core/src/test/java/feign/codec/MultiDecoderTest.java new file mode 100644 index 000000000..b1babf6c4 --- /dev/null +++ b/core/src/test/java/feign/codec/MultiDecoderTest.java @@ -0,0 +1,260 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.Request; +import feign.Request.HttpMethod; +import feign.Response; +import feign.Util; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MultiDecoderTest { + + /** A plain decoder, with no opinion about what it can handle. */ + private static class RecordingDecoder implements Decoder { + private final String result; + boolean invoked; + + RecordingDecoder(String result) { + this.result = result; + } + + @Override + public Object decode(Response response, Type type) { + invoked = true; + return result; + } + } + + /** A decoder that declares its own applicability, the way feign-gson and friends now do. */ + private static class SelfDeclaringJsonDecoder extends RecordingDecoder + implements PredicatedDecoder { + + SelfDeclaringJsonDecoder() { + super("json"); + } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } + } + + private static Response responseWithContentType(String contentType) { + return responseWithContentType(contentType, 200, "body"); + } + + private static Response responseWithContentType(String contentType, int status, String body) { + Map> headers = new HashMap<>(); + if (contentType != null) { + headers.put("Content-Type", Collections.singletonList(contentType)); + } + Response.Builder builder = + Response.builder() + .status(status) + .reason("OK") + .headers(headers) + .request( + Request.create( + HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8, null)); + if (body != null) { + builder.body(body, Util.UTF_8); + } + return builder.build(); + } + + @Test + void routesToTheDecoderThatDeclaresItCanHandleTheResponse() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + + assertThat(decoder.decode(responseWithContentType("application/json"), String.class)) + .isEqualTo("json"); + assertThat(json.invoked).isTrue(); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void pairsAPredicateWithADecoderThatDoesNotDeclareItself() throws IOException { + RecordingDecoder xml = new RecordingDecoder("xml"); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = + MultiDecoder.builder(fallback).add(DecoderPredicate.xmlContentType(), xml).build(); + + assertThat(decoder.decode(responseWithContentType("application/xml"), String.class)) + .isEqualTo("xml"); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void mixesSelfDeclaringDecodersAndPairs() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder xml = new RecordingDecoder("xml"); + RecordingDecoder csv = new RecordingDecoder("csv"); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = + MultiDecoder.builder(fallback) + .add(json) + .add(DecoderPredicate.xmlContentType(), xml) + .add(DecoderPredicate.contentType("text/csv"), csv) + .build(); + + assertThat(decoder.decode(responseWithContentType("text/csv;charset=utf-8"), String.class)) + .isEqualTo("csv"); + assertThat(json.invoked).isFalse(); + assertThat(xml.invoked).isFalse(); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void matchesSuffixedContentTypes() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + + assertThat(decoder.decode(responseWithContentType("application/vnd.github+json"), String.class)) + .isEqualTo("json"); + } + + @Test + void fallsBackToTheDefaultDecoderWhenNoDelegateAccepts() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + + assertThat(decoder.decode(responseWithContentType("text/plain"), String.class)) + .isEqualTo("fallback"); + assertThat(json.invoked).isFalse(); + } + + @Test + void fallsBackWhenTheResponseCarriesNoContentType() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + + assertThat(decoder.decode(responseWithContentType(null), String.class)).isEqualTo("fallback"); + } + + @Test + void consultsDelegatesInTheOrderTheyWereAdded() throws IOException { + RecordingDecoder first = new RecordingDecoder("first"); + RecordingDecoder second = new RecordingDecoder("second"); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = + MultiDecoder.builder(fallback) + .add(DecoderPredicate.jsonContentType(), first) + .add(DecoderPredicate.jsonContentType(), second) + .build(); + + assertThat(decoder.decode(responseWithContentType("application/json"), String.class)) + .isEqualTo("first"); + assertThat(second.invoked).isFalse(); + } + + @Test + void aBareLambdaIsADecoderThatAcceptsEverything() throws IOException { + PredicatedDecoder anything = (response, type) -> "anything"; + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = MultiDecoder.builder(fallback).add(anything).build(); + + assertThat(decoder.decode(responseWithContentType("text/plain"), String.class)) + .isEqualTo("anything"); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void propagatesIoExceptionsFromTheSelectedDecoder() { + Decoder failing = + (response, type) -> { + throw new IOException("boom"); + }; + + Decoder decoder = + MultiDecoder.builder(new RecordingDecoder("fallback")) + .add(DecoderPredicate.jsonContentType(), failing) + .build(); + + assertThatThrownBy( + () -> decoder.decode(responseWithContentType("application/json"), String.class)) + .isInstanceOf(IOException.class) + .hasMessage("boom"); + } + + @Test + void rejectsANullDefaultDecoder() { + assertThatThrownBy(() -> MultiDecoder.builder(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("defaultDecoder cannot be null"); + } + + @Test + void rejectsNullDelegates() { + MultiDecoder.Builder builder = MultiDecoder.builder(new RecordingDecoder("fallback")); + + assertThatThrownBy(() -> builder.add((PredicatedDecoder) null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("decoder cannot be null"); + assertThatThrownBy(() -> builder.add(null, new RecordingDecoder("x"))) + .isInstanceOf(NullPointerException.class) + .hasMessage("predicate cannot be null"); + assertThatThrownBy(() -> builder.add(DecoderPredicate.jsonContentType(), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("decoder cannot be null"); + } + + @Test + void describesItsDelegates() { + Decoder decoder = + MultiDecoder.builder( + new RecordingDecoder("fallback") { + @Override + public String toString() { + return "fallback"; + } + }) + .add( + DecoderPredicate.jsonContentType(), + new RecordingDecoder("json") { + @Override + public String toString() { + return "json"; + } + }) + .build(); + + assertThat(decoder.toString()) + .isEqualTo("MultiDecoder{defaultDecoder=fallback, delegates=[json]}"); + } +} From 02078d3821253f73faa15953a9cd0bf84d2a89e9 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:28:26 -0300 Subject: [PATCH 37/45] Expose and document multi-decoder configuration Signed-off-by: Marvin Froeder --- README.md | 76 +++++++ core/src/main/java/feign/BaseBuilder.java | 28 +++ .../codec/MultiDecoderCapabilityTest.java | 207 ++++++++++++++++++ src/docs/overview-mindmap.iuml | 1 + 4 files changed, 312 insertions(+) create mode 100644 core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java diff --git a/README.md b/README.md index cbaae1076..12f1b7601 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,82 @@ public class Example { } ``` +#### Multiple decoders + +> This API is `@Experimental` and may change incompatibly, or be removed, in a future release. + +A single client sometimes has to read more than one format — JSON for most endpoints, XML for +a legacy one, plain text for a health check. `MultiDecoder` routes each response to the right +decoder, falling back to a default when none applies. + +Most first-party decoders already declare what they can handle, so they can simply be added: + +```java +interface MixedClient { + @RequestLine("GET /orders/{id}") + Order order(@Param("id") String id); + + @RequestLine("GET /legacy/orders/{id}") + Order legacyOrder(@Param("id") String id); +} + +public class Example { + public static void main(String[] args) { + MixedClient client = Feign.builder() + .decoder(new DefaultDecoder(), new GsonDecoder(), new JAXBDecoder()) + .target(MixedClient.class, "https://foo.com"); + } +} +``` + +The first argument is the default decoder, used when nothing else accepts the response. Routing is +driven by what the server actually sent back, so a client that talks to endpoints answering +`application/json` and `application/xml` no longer needs one Feign instance per format. + +For a decoder that does not declare itself — including one you do not control — pair it +with a `DecoderPredicate` using the builder: + +```java +Decoder decoder = + MultiDecoder.builder(new DefaultDecoder()) + .add(new GsonDecoder()) // declares itself + .add(DecoderPredicate.xmlContentType(), someXmlDecoder) // paired + .add((response, type) -> type == byte[].class, binaryDecoder) + .build(); +``` + +Delegates are consulted in the order they were added, so put the narrowest predicate first. + +##### Declaring your own decoder + +Implement `PredicatedDecoder` alongside `Decoder` and override `canDecode`: + +```java +public class MyDecoder implements Decoder, PredicatedDecoder { + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } + + @Override + public Object decode(Response response, Type type) throws IOException { + // ... + } +} +``` + +`DecoderPredicate` ships with `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, +`emptyBody()`, `status(codes...)` and `returnType(type)`, plus `and`/`or`/`negate` to combine them. + +**Predicates must not read the response body.** For most clients it is a single-pass stream, so +consuming it in `canDecode` would leave nothing for the decoder that is eventually chosen. Decide +on the status, the headers and the expected type instead. + +**If you wrap a decoder, forward `canDecode` to your delegate.** A wrapper that does not will claim +every response, because the default `canDecode` accepts everything. `OptionalDecoder` and the +metrics modules' `MeteredDecoder` forward for exactly this reason. + ### Encoders The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index 754fcd306..59526ace0 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -27,6 +27,8 @@ import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; import feign.codec.ErrorDecoder; +import feign.codec.MultiDecoder; +import feign.codec.PredicatedDecoder; import feign.interceptor.MethodInterceptor; import feign.interceptor.MethodInterceptors; import java.lang.reflect.Field; @@ -99,6 +101,32 @@ public B decoder(Decoder decoder) { return thisB(); } + /** + * Configures a {@link MultiDecoder} built from decoders that declare their own applicability. + * + *

Each {@link PredicatedDecoder} is consulted in the order given; {@code defaultDecoder} is + * the fallback used when none accepts the response. + * + *

+   * Feign.builder()
+   *     .decoder(new DefaultDecoder(), new JacksonDecoder(), new JAXBDecoder())
+   * 
+ * + *

To pair a predicate with a decoder that does not implement {@link PredicatedDecoder}, use + * {@link MultiDecoder#builder(Decoder)} instead. + * + * @param defaultDecoder the decoder used when no delegate accepts the response + * @param decoders the predicated decoders, consulted in the order given + */ + @Experimental + public B decoder(Decoder defaultDecoder, PredicatedDecoder... decoders) { + MultiDecoder.Builder builder = MultiDecoder.builder(defaultDecoder); + for (PredicatedDecoder decoder : decoders) { + builder.add(decoder); + } + return decoder(builder.build()); + } + public B codec(Codec codec) { this.encoder = codec.encoder(); this.decoder = codec.decoder(); diff --git a/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java new file mode 100644 index 000000000..024a3c94a --- /dev/null +++ b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java @@ -0,0 +1,207 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import static org.assertj.core.api.Assertions.assertThat; + +import feign.Capability; +import feign.Feign; +import feign.Param; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestLine; +import feign.Response; +import feign.Util; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** How {@link MultiDecoder} behaves end to end and when a {@link Capability} wraps the decoder. */ +class MultiDecoderCapabilityTest { + + interface MixedApi { + @RequestLine("GET /{path}") + String get(@Param("path") String path); + } + + static class TaggingDecoder implements Decoder { + private final String tag; + + TaggingDecoder(String tag) { + this.tag = tag; + } + + @Override + public Object decode(Response response, Type type) { + return tag; + } + } + + /** A capability that wraps the decoder, the way the metrics modules do. */ + public static class CountingCapability implements Capability { + int wrapped; + int decodeCalls; + + @Override + public Decoder enrich(Decoder decoder) { + wrapped++; + return (response, type) -> { + decodeCalls++; + return decoder.decode(response, type); + }; + } + } + + private static Response response(String contentType, String body) { + return response( + contentType, + body, + Request.create( + HttpMethod.GET, "http://localhost:1/", Collections.emptyMap(), null, Util.UTF_8, null)); + } + + private static Response response(String contentType, String body, Request request) { + Map> headers = new HashMap<>(); + headers.put("Content-Type", Collections.singletonList(contentType)); + return Response.builder() + .status(200) + .reason("OK") + .headers(headers) + .body(body, Util.UTF_8) + .request(request) + .build(); + } + + private static MixedApi target(Feign.Builder builder, Map contentTypes) { + return builder + .client( + (request, options) -> { + String path = request.url().substring(request.url().lastIndexOf('/') + 1); + return response(contentTypes.get(path), "payload", request); + }) + .target(MixedApi.class, "http://localhost:1"); + } + + @Test + void capabilityWrapsTheCompositeAndRoutingStillWorks() { + CountingCapability capability = new CountingCapability(); + Map contentTypes = new HashMap<>(); + contentTypes.put("json", "application/json"); + contentTypes.put("xml", "application/xml"); + + MixedApi api = + target( + Feign.builder() + .decoder( + MultiDecoder.builder(new TaggingDecoder("fallback")) + .add(DecoderPredicate.jsonContentType(), new TaggingDecoder("json")) + .add(DecoderPredicate.xmlContentType(), new TaggingDecoder("xml")) + .build()) + .addCapability(capability), + contentTypes); + + assertThat(api.get("json")).isEqualTo("json"); + assertThat(api.get("xml")).isEqualTo("xml"); + + // the capability sees the MultiDecoder as one decoder, not one per delegate + assertThat(capability.wrapped).isEqualTo(1); + assertThat(capability.decodeCalls).isEqualTo(2); + } + + @Test + void builderShorthandRoutesToSelfDeclaringDecoders() { + Map contentTypes = new HashMap<>(); + contentTypes.put("json", "application/json"); + contentTypes.put("csv", "text/csv"); + + MixedApi api = + target( + Feign.builder().decoder(new TaggingDecoder("fallback"), new SelfDeclaringJsonDecoder()), + contentTypes); + + assertThat(api.get("json")).isEqualTo("json"); + assertThat(api.get("csv")).isEqualTo("fallback"); + } + + /** The selected decoder still receives an unread body: predicates must not consume it. */ + @Test + void predicatesLeaveTheBodyForTheSelectedDecoder() throws IOException { + Decoder decoder = + MultiDecoder.builder(new TaggingDecoder("fallback")) + .add( + DecoderPredicate.jsonContentType(), + (response, type) -> Util.toString(response.body().asReader(Util.UTF_8))) + .build(); + + assertThat(decoder.decode(response("application/json", "payload"), String.class)) + .isEqualTo("payload"); + } + + static class SelfDeclaringJsonDecoder implements Decoder, PredicatedDecoder { + + @Override + public Object decode(Response response, Type type) { + return "json"; + } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } + } + + /** + * A wrapper that does not forward {@code canDecode} claims every response, which is why the + * metrics modules' {@code MeteredDecoder} forwards it to its delegate. + */ + @Test + void wrappingWithoutForwardingCanDecodeErasesSelfDeclaration() throws IOException { + PredicatedDecoder jsonOnly = new SelfDeclaringJsonDecoder(); + + PredicatedDecoder naive = jsonOnly::decode; + + PredicatedDecoder forwarding = + new PredicatedDecoder() { + @Override + public boolean canDecode(Response response, Type type) { + return jsonOnly.canDecode(response, type); + } + + @Override + public Object decode(Response response, Type type) throws IOException { + return jsonOnly.decode(response, type); + } + }; + + assertThat( + MultiDecoder.builder(new TaggingDecoder("fallback")) + .add(naive) + .build() + .decode(response("application/xml", "payload"), String.class)) + .isEqualTo("json"); + + assertThat( + MultiDecoder.builder(new TaggingDecoder("fallback")) + .add(forwarding) + .build() + .decode(response("application/xml", "payload"), String.class)) + .isEqualTo("fallback"); + } +} diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index afd6aefbf..b567ede3e 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -31,6 +31,7 @@ left side ** encoders/decoders +*** Multi decoder (predicate based, experimental) *** GSON *** JAXB *** JAXB Jakarta From 93d52d647d2bcbdd1b9fd672384cb1990ee7108b Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:28:35 -0300 Subject: [PATCH 38/45] Declare applicability on the first-party decoders Signed-off-by: Marvin Froeder --- CHANGELOG.md | 9 +++++++++ .../main/java/feign/optionals/OptionalDecoder.java | 13 ++++++++++++- .../main/java/feign/metrics4/MeteredDecoder.java | 9 ++++++++- .../main/java/feign/metrics5/MeteredDecoder.java | 9 ++++++++- .../main/java/feign/fastjson2/Fastjson2Decoder.java | 8 +++++++- gson/src/main/java/feign/gson/GsonDecoder.java | 8 +++++++- .../feign/jackson/jaxb/JacksonJaxbJsonDecoder.java | 8 +++++++- .../java/feign/jackson/jr/JacksonJrDecoder.java | 9 ++++++++- .../src/main/java/feign/jackson/JacksonDecoder.java | 8 +++++++- .../main/java/feign/jackson3/Jackson3Decoder.java | 8 +++++++- .../src/main/java/feign/jaxb/JAXBDecoder.java | 8 +++++++- jaxb/src/main/java/feign/jaxb/JAXBDecoder.java | 8 +++++++- json/src/main/java/feign/json/JsonDecoder.java | 8 +++++++- .../main/java/feign/micrometer/MeteredDecoder.java | 9 ++++++++- moshi/src/main/java/feign/moshi/MoshiDecoder.java | 8 +++++++- sax/src/main/java/feign/sax/SAXDecoder.java | 8 +++++++- .../src/main/java/feign/soap/SOAPDecoder.java | 8 +++++++- soap/src/main/java/feign/soap/SOAPDecoder.java | 8 +++++++- 18 files changed, 137 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 513e923ab..eac92e641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ### Version 13.14 +* Add `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, letting a single + client route each response to the right decoder. Decoders declare what they can handle by + implementing `PredicatedDecoder`; anything else is paired with a predicate via + `MultiDecoder.builder(defaultDecoder)`. The first-party JSON decoders (Gson, Jackson, Jackson 3, + Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java) and XML decoders (JAXB, JAXB Jakarta, SAX, + SOAP, SOAP Jakarta) now declare themselves, and `OptionalDecoder` and the metrics modules' + `MeteredDecoder` forward `canDecode` to the decoder they wrap. The `Decoder` interface is + unchanged, so existing decoders keep working. + * `JAXBContextFactory.withProperty` is now applied when creating Unmarshallers, not only Marshallers. Marshaller-only properties are skipped on unmarshal (#3056). * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a diff --git a/core/src/main/java/feign/optionals/OptionalDecoder.java b/core/src/main/java/feign/optionals/OptionalDecoder.java index 475ee74b9..0edb3bede 100644 --- a/core/src/main/java/feign/optionals/OptionalDecoder.java +++ b/core/src/main/java/feign/optionals/OptionalDecoder.java @@ -18,13 +18,14 @@ import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.util.Objects; import java.util.Optional; -public final class OptionalDecoder implements Decoder { +public final class OptionalDecoder implements Decoder, PredicatedDecoder { final Decoder delegate; public OptionalDecoder(Decoder delegate) { @@ -44,6 +45,16 @@ public Object decode(Response response, Type type) throws IOException { return Optional.ofNullable(delegate.decode(response, enclosedType)); } + @Override + public boolean canDecode(Response response, Type type) { + if (!(delegate instanceof PredicatedDecoder)) { + return true; + } + Type enclosedType = + isOptional(type) ? Util.resolveLastTypeParameter(type, Optional.class) : type; + return ((PredicatedDecoder) delegate).canDecode(response, enclosedType); + } + static boolean isOptional(Type type) { if (!(type instanceof ParameterizedType)) { return false; diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java index e47fbe852..b51d154b2 100644 --- a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java +++ b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java @@ -22,11 +22,12 @@ import feign.Response; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.lang.reflect.Type; /** Warp feign {@link Decoder} with metrics. */ -public class MeteredDecoder implements Decoder { +public class MeteredDecoder implements Decoder, PredicatedDecoder { private final Decoder decoder; private final MetricRegistry metricRegistry; @@ -73,4 +74,10 @@ public Object decode(Response response, Type type) return decoded; } + + @Override + public boolean canDecode(Response response, Type type) { + return !(decoder instanceof PredicatedDecoder) + || ((PredicatedDecoder) decoder).canDecode(response, type); + } } diff --git a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java index 653d29e62..5f56b5770 100644 --- a/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java +++ b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java @@ -20,6 +20,7 @@ import feign.Response; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import feign.utils.ExceptionUtils; import io.dropwizard.metrics5.MetricRegistry; import io.dropwizard.metrics5.Timer.Context; @@ -28,7 +29,7 @@ import java.util.Map; /** Warp feign {@link Decoder} with metrics. */ -public class MeteredDecoder implements Decoder { +public class MeteredDecoder implements Decoder, PredicatedDecoder { private final Decoder decoder; private final MetricRegistry metricRegistry; @@ -110,4 +111,10 @@ public Object decode(Response response, Type type) return decoded; } + + @Override + public boolean canDecode(Response response, Type type) { + return !(decoder instanceof PredicatedDecoder) + || ((PredicatedDecoder) decoder).canDecode(response, type); + } } diff --git a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java index 80b1ada8d..e8631ae0c 100644 --- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java @@ -26,6 +26,7 @@ import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; @@ -33,7 +34,7 @@ /** * @author changjin wei(魏昌进) */ -public class Fastjson2Decoder implements Decoder, JsonDecoder { +public class Fastjson2Decoder implements Decoder, PredicatedDecoder, JsonDecoder { private final JSONReader.Feature[] features; @@ -69,4 +70,9 @@ public Object convert(Object object, Type type) { } return JSON.parseObject(JSON.toJSONString(object), type); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java index 5fa6ee036..8a908087c 100644 --- a/gson/src/main/java/feign/gson/GsonDecoder.java +++ b/gson/src/main/java/feign/gson/GsonDecoder.java @@ -24,12 +24,13 @@ import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.util.Collections; -public class GsonDecoder implements Decoder, JsonDecoder { +public class GsonDecoder implements Decoder, PredicatedDecoder, JsonDecoder { private final Gson gson; @@ -66,4 +67,9 @@ public Object decode(Response response, Type type) throws IOException { public Object convert(Object object, Type type) { return gson.fromJson(gson.toJsonTree(object), type); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java index ed1cb12a2..98a3e0e83 100644 --- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java @@ -24,10 +24,11 @@ import feign.Response; import feign.Util; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.lang.reflect.Type; -public final class JacksonJaxbJsonDecoder implements Decoder { +public final class JacksonJaxbJsonDecoder implements Decoder, PredicatedDecoder { private final JacksonJaxbJsonProvider jacksonJaxbJsonProvider; public JacksonJaxbJsonDecoder() { @@ -45,4 +46,9 @@ public Object decode(Response response, Type type) throws IOException, FeignExce return jacksonJaxbJsonProvider.readFrom( Object.class, type, null, APPLICATION_JSON_TYPE, null, response.body().asInputStream()); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java index 3edb1f8dd..7d4581f5f 100644 --- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java +++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java @@ -23,6 +23,7 @@ import feign.codec.DecodeException; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; @@ -34,7 +35,8 @@ /** * A {@link JsonDecoder} that uses Jackson Jr to convert objects to String or byte representation. */ -public class JacksonJrDecoder extends JacksonJrMapper implements Decoder, JsonDecoder { +public class JacksonJrDecoder extends JacksonJrMapper + implements Decoder, PredicatedDecoder, JsonDecoder { @FunctionalInterface protected interface Transformer { @@ -134,4 +136,9 @@ public Object convert(Object object, Type type) throws IOException { } throw new IOException("Cannot convert to type: " + type.getTypeName()); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/jackson/src/main/java/feign/jackson/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java index 370f745dc..db5a0dbe5 100644 --- a/jackson/src/main/java/feign/jackson/JacksonDecoder.java +++ b/jackson/src/main/java/feign/jackson/JacksonDecoder.java @@ -23,13 +23,14 @@ import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.util.Collections; -public class JacksonDecoder implements Decoder, JsonDecoder { +public class JacksonDecoder implements Decoder, PredicatedDecoder, JsonDecoder { private final ObjectMapper mapper; @@ -76,4 +77,9 @@ public Object decode(Response response, Type type) throws IOException { public Object convert(Object object, Type type) { return mapper.convertValue(object, mapper.constructType(type)); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java index 5726d582b..363b17287 100644 --- a/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java @@ -19,6 +19,7 @@ import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; @@ -29,7 +30,7 @@ import tools.jackson.databind.JacksonModule; import tools.jackson.databind.json.JsonMapper; -public class Jackson3Decoder implements Decoder, JsonDecoder { +public class Jackson3Decoder implements Decoder, PredicatedDecoder, JsonDecoder { private final JsonMapper mapper; @@ -77,4 +78,9 @@ public Object decode(Response response, Type type) throws IOException { public Object convert(Object object, Type type) { return mapper.convertValue(object, mapper.constructType(type)); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java index 6a40861cd..feaf8237b 100644 --- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java @@ -19,6 +19,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import jakarta.xml.bind.JAXBException; import java.io.IOException; import java.lang.reflect.ParameterizedType; @@ -48,7 +49,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBDecoder implements Decoder { +public class JAXBDecoder implements Decoder, PredicatedDecoder { private final JAXBContextFactory jaxbContextFactory; private final boolean namespaceAware; @@ -123,4 +124,9 @@ public JAXBDecoder build() { return new JAXBDecoder(this); } } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isXmlContentType(response); + } } diff --git a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java index 9d998d26d..a7132472e 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java @@ -19,6 +19,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; @@ -48,7 +49,7 @@ *

The JAXBContextFactory should be reused across requests as it caches the created JAXB * contexts. */ -public class JAXBDecoder implements Decoder { +public class JAXBDecoder implements Decoder, PredicatedDecoder { private final JAXBContextFactory jaxbContextFactory; private final boolean namespaceAware; @@ -123,4 +124,9 @@ public JAXBDecoder build() { return new JAXBDecoder(this); } } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isXmlContentType(response); + } } diff --git a/json/src/main/java/feign/json/JsonDecoder.java b/json/src/main/java/feign/json/JsonDecoder.java index edf7fd80f..5f0a26486 100644 --- a/json/src/main/java/feign/json/JsonDecoder.java +++ b/json/src/main/java/feign/json/JsonDecoder.java @@ -21,6 +21,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.BufferedReader; import java.io.IOException; import java.io.Reader; @@ -53,7 +54,7 @@ * System.out.println(contributors.getJSONObject(0).getString("login")); * */ -public class JsonDecoder implements Decoder, feign.codec.JsonDecoder { +public class JsonDecoder implements Decoder, PredicatedDecoder, feign.codec.JsonDecoder { @Override public Object decode(Response response, Type type) throws IOException, DecodeException { @@ -114,4 +115,9 @@ public Object convert(Object object, Type type) throws IOException { } throw new IOException(type.getTypeName() + " is not a type supported by this decoder."); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java index 65b8067eb..926452254 100644 --- a/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java +++ b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java @@ -19,6 +19,7 @@ import feign.RequestTemplate; import feign.Response; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import feign.utils.ExceptionUtils; import io.micrometer.core.instrument.*; import java.io.IOException; @@ -26,7 +27,7 @@ import java.util.Optional; /** Wrap feign {@link Decoder} with metrics. */ -public class MeteredDecoder implements Decoder { +public class MeteredDecoder implements Decoder, PredicatedDecoder { private final Decoder decoder; private final MeterRegistry meterRegistry; @@ -117,4 +118,10 @@ protected Tag[] extraTags(Response response, Type type, Exception e) { RequestTemplate template = response.request().requestTemplate(); return new Tag[] {Tag.of("uri", template.methodMetadata().template().path())}; } + + @Override + public boolean canDecode(Response response, Type type) { + return !(decoder instanceof PredicatedDecoder) + || ((PredicatedDecoder) decoder).canDecode(response, type); + } } diff --git a/moshi/src/main/java/feign/moshi/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java index ac08ee96a..9f4e006f4 100644 --- a/moshi/src/main/java/feign/moshi/MoshiDecoder.java +++ b/moshi/src/main/java/feign/moshi/MoshiDecoder.java @@ -22,12 +22,13 @@ import feign.Util; import feign.codec.Decoder; import feign.codec.JsonDecoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.lang.reflect.Type; import okio.BufferedSource; import okio.Okio; -public class MoshiDecoder implements Decoder, JsonDecoder { +public class MoshiDecoder implements Decoder, PredicatedDecoder, JsonDecoder { private final Moshi moshi; public MoshiDecoder(Moshi moshi) { @@ -67,4 +68,9 @@ public Object convert(Object object, Type type) throws IOException { JsonAdapter adapter = moshi.adapter(type); return adapter.fromJsonValue(object); } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } } diff --git a/sax/src/main/java/feign/sax/SAXDecoder.java b/sax/src/main/java/feign/sax/SAXDecoder.java index 6aa799d0a..20da34bea 100644 --- a/sax/src/main/java/feign/sax/SAXDecoder.java +++ b/sax/src/main/java/feign/sax/SAXDecoder.java @@ -24,6 +24,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Constructor; @@ -53,7 +54,7 @@ * .target(MyApi.class, "http://api"); * */ -public class SAXDecoder implements Decoder { +public class SAXDecoder implements Decoder, PredicatedDecoder { private final Map> handlerFactories; @@ -176,4 +177,9 @@ public ContentHandlerWithResult create() { } } } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isXmlContentType(response); + } } diff --git a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java index 37386f026..619ce01d7 100644 --- a/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java +++ b/soap-jakarta/src/main/java/feign/soap/SOAPDecoder.java @@ -19,6 +19,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import feign.jaxb.JAXBContextFactory; import jakarta.xml.bind.JAXBException; import jakarta.xml.bind.Unmarshaller; @@ -75,7 +76,7 @@ * @see SOAPErrorDecoder * @see SOAPFaultException */ -public class SOAPDecoder implements Decoder { +public class SOAPDecoder implements Decoder, PredicatedDecoder { private final JAXBContextFactory jaxbContextFactory; private final String soapProtocol; @@ -175,4 +176,9 @@ public SOAPDecoder build() { return new SOAPDecoder(this); } } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isXmlContentType(response); + } } diff --git a/soap/src/main/java/feign/soap/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java index 8079a622a..bf63ba4c2 100644 --- a/soap/src/main/java/feign/soap/SOAPDecoder.java +++ b/soap/src/main/java/feign/soap/SOAPDecoder.java @@ -19,6 +19,7 @@ import feign.Util; import feign.codec.DecodeException; import feign.codec.Decoder; +import feign.codec.PredicatedDecoder; import feign.jaxb.JAXBContextFactory; import java.io.IOException; import java.lang.reflect.ParameterizedType; @@ -79,7 +80,7 @@ * @see SOAPErrorDecoder * @see SOAPFaultException */ -public class SOAPDecoder implements Decoder { +public class SOAPDecoder implements Decoder, PredicatedDecoder { private final JAXBContextFactory jaxbContextFactory; private final String soapProtocol; @@ -179,4 +180,9 @@ public SOAPDecoder build() { return new SOAPDecoder(this); } } + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isXmlContentType(response); + } } From 372ebee2551bb483bc7e55ea9768981b6d55a59d Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:37:46 -0300 Subject: [PATCH 39/45] prepare release 13.14 --- annotation-error-decoder/pom.xml | 2 +- apt-test-generator/pom.xml | 2 +- benchmark/pom.xml | 2 +- core/pom.xml | 2 +- dropwizard-metrics4/pom.xml | 2 +- dropwizard-metrics5/pom.xml | 2 +- example-github-with-coroutine/pom.xml | 2 +- example-github/pom.xml | 2 +- example-wikipedia-with-springboot/pom.xml | 2 +- example-wikipedia/pom.xml | 2 +- fastjson2/pom.xml | 2 +- feign-bom/pom.xml | 90 +++++++++++------------ form-spring/pom.xml | 2 +- form/pom.xml | 2 +- googlehttpclient/pom.xml | 2 +- graphql-apt/pom.xml | 2 +- graphql/pom.xml | 2 +- gson/pom.xml | 2 +- hc5/pom.xml | 2 +- http-cache/pom.xml | 2 +- httpclient/pom.xml | 2 +- hystrix/pom.xml | 2 +- jackson-jaxb/pom.xml | 2 +- jackson-jr/pom.xml | 2 +- jackson/pom.xml | 2 +- jackson3/pom.xml | 2 +- jakarta/pom.xml | 2 +- java11/pom.xml | 2 +- jaxb-jakarta/pom.xml | 2 +- jaxb/pom.xml | 2 +- jaxrs/pom.xml | 2 +- jaxrs2/pom.xml | 2 +- jaxrs3/pom.xml | 2 +- jaxrs4/pom.xml | 2 +- json/pom.xml | 2 +- kotlin/pom.xml | 2 +- micrometer/pom.xml | 2 +- mock/pom.xml | 2 +- moshi/pom.xml | 2 +- okhttp/pom.xml | 2 +- pom.xml | 2 +- reactive/pom.xml | 2 +- ribbon/pom.xml | 2 +- sax/pom.xml | 2 +- slf4j/pom.xml | 2 +- soap-jakarta/pom.xml | 2 +- soap/pom.xml | 2 +- spring/pom.xml | 2 +- spring4/pom.xml | 2 +- validation-jakarta/pom.xml | 2 +- validation/pom.xml | 2 +- vertx/feign-vertx/pom.xml | 2 +- vertx/feign-vertx4-test/pom.xml | 2 +- vertx/feign-vertx5-test/pom.xml | 2 +- vertx/pom.xml | 2 +- 55 files changed, 99 insertions(+), 99 deletions(-) diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml index f6ef0447f..fcbcadcfa 100644 --- a/annotation-error-decoder/pom.xml +++ b/annotation-error-decoder/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-annotation-error-decoder diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml index 1617a5eff..f93b972e0 100644 --- a/apt-test-generator/pom.xml +++ b/apt-test-generator/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 io.github.openfeign.experimental diff --git a/benchmark/pom.xml b/benchmark/pom.xml index 309526796..d6c015f77 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-benchmark diff --git a/core/pom.xml b/core/pom.xml index f33eed07a..eea20f9de 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-core diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml index 834db1a74..791529fe0 100644 --- a/dropwizard-metrics4/pom.xml +++ b/dropwizard-metrics4/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-dropwizard-metrics4 Feign Dropwizard Metrics4 diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml index 03633fdff..6a825a4fc 100644 --- a/dropwizard-metrics5/pom.xml +++ b/dropwizard-metrics5/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-dropwizard-metrics5 Feign Dropwizard Metrics5 diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml index af9e97ccc..d75216507 100644 --- a/example-github-with-coroutine/pom.xml +++ b/example-github-with-coroutine/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-example-github-with-coroutine diff --git a/example-github/pom.xml b/example-github/pom.xml index 5f38c01dd..2f18b7f9c 100644 --- a/example-github/pom.xml +++ b/example-github/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-example-github diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml index 066b63d52..1e2e8279d 100644 --- a/example-wikipedia-with-springboot/pom.xml +++ b/example-wikipedia-with-springboot/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-example-wikipedia-with-springboot diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml index 014bfdd0a..93627e0fb 100644 --- a/example-wikipedia/pom.xml +++ b/example-wikipedia/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 io.github.openfeign diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml index 8b1ae28bb..86d1b9045 100644 --- a/fastjson2/pom.xml +++ b/fastjson2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-fastjson2 diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index c2e7a464f..89dfa446f 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -28,7 +28,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 ../pom.xml @@ -42,222 +42,222 @@ io.github.openfeign feign-core - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-gson - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-http-cache - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxrs - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-httpclient - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxrs2 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-hc5 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-hystrix - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jackson - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jackson3 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jackson-jaxb - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jackson-jr - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxb - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxb-jakarta - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxrs3 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jaxrs4 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-java11 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-jakarta - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-mock - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-json - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-okhttp - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-googlehttpclient - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-ribbon - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-sax - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-slf4j - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-spring - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-soap - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-soap-jakarta - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-reactive-wrappers - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-micrometer - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-dropwizard-metrics4 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-dropwizard-metrics5 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-kotlin - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-graphql - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-annotation-error-decoder - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-form - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-form-spring - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-moshi - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-fastjson2 - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-validation - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-validation-jakarta - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-vertx - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-vertx4-test - 13.14-SNAPSHOT + 13.14 io.github.openfeign feign-vertx5-test - 13.14-SNAPSHOT + 13.14 diff --git a/form-spring/pom.xml b/form-spring/pom.xml index 5561fb99f..50bdd6192 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-form-spring diff --git a/form/pom.xml b/form/pom.xml index b248eecfb..54635dcd5 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-form diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml index c653ac1f3..8dd3e104d 100644 --- a/googlehttpclient/pom.xml +++ b/googlehttpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-googlehttpclient diff --git a/graphql-apt/pom.xml b/graphql-apt/pom.xml index 10d645fa6..d0cf50993 100644 --- a/graphql-apt/pom.xml +++ b/graphql-apt/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 io.github.openfeign.experimental diff --git a/graphql/pom.xml b/graphql/pom.xml index 9d931fcdd..6e55d2452 100644 --- a/graphql/pom.xml +++ b/graphql/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-graphql diff --git a/gson/pom.xml b/gson/pom.xml index eadea8b12..f8fee04e7 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-gson diff --git a/hc5/pom.xml b/hc5/pom.xml index 30a538f44..3275d3207 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-hc5 diff --git a/http-cache/pom.xml b/http-cache/pom.xml index c6ef73808..06cb58681 100644 --- a/http-cache/pom.xml +++ b/http-cache/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-http-cache diff --git a/httpclient/pom.xml b/httpclient/pom.xml index a24f5671b..c474c56f9 100644 --- a/httpclient/pom.xml +++ b/httpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-httpclient diff --git a/hystrix/pom.xml b/hystrix/pom.xml index 42cf19cf4..30bb642f8 100644 --- a/hystrix/pom.xml +++ b/hystrix/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-hystrix diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml index 5d0ab9493..b2727ff00 100644 --- a/jackson-jaxb/pom.xml +++ b/jackson-jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jackson-jaxb diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml index 33e15c76f..5bb6036a9 100644 --- a/jackson-jr/pom.xml +++ b/jackson-jr/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jackson-jr diff --git a/jackson/pom.xml b/jackson/pom.xml index e5f58a13e..2378b46c3 100644 --- a/jackson/pom.xml +++ b/jackson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jackson diff --git a/jackson3/pom.xml b/jackson3/pom.xml index f95ab2587..b79498533 100644 --- a/jackson3/pom.xml +++ b/jackson3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jackson3 diff --git a/jakarta/pom.xml b/jakarta/pom.xml index b026ef1a5..8c314ba7b 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jakarta diff --git a/java11/pom.xml b/java11/pom.xml index 15b342e11..990c17603 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-java11 diff --git a/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml index bfb3501e9..1f5508f8b 100644 --- a/jaxb-jakarta/pom.xml +++ b/jaxb-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxb-jakarta diff --git a/jaxb/pom.xml b/jaxb/pom.xml index 7e45e6610..9473704c6 100644 --- a/jaxb/pom.xml +++ b/jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxb diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml index 3647078c3..140394765 100644 --- a/jaxrs/pom.xml +++ b/jaxrs/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxrs diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml index c583e9fe8..0317adefd 100644 --- a/jaxrs2/pom.xml +++ b/jaxrs2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxrs2 diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml index 1d3960ab5..1f70cc34b 100644 --- a/jaxrs3/pom.xml +++ b/jaxrs3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxrs3 diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml index 397f4ea1a..a3ef12c8a 100644 --- a/jaxrs4/pom.xml +++ b/jaxrs4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-jaxrs4 diff --git a/json/pom.xml b/json/pom.xml index 759208abb..407e4d2a0 100644 --- a/json/pom.xml +++ b/json/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-json diff --git a/kotlin/pom.xml b/kotlin/pom.xml index 4f757250d..c192d4dc9 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-kotlin diff --git a/micrometer/pom.xml b/micrometer/pom.xml index e4f85282a..de7fc7bd8 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-micrometer Feign Micrometer diff --git a/mock/pom.xml b/mock/pom.xml index 5fef7b967..af245289f 100644 --- a/mock/pom.xml +++ b/mock/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-mock diff --git a/moshi/pom.xml b/moshi/pom.xml index a46bc101b..2702c1796 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-moshi diff --git a/okhttp/pom.xml b/okhttp/pom.xml index 159ed7329..965c407f7 100644 --- a/okhttp/pom.xml +++ b/okhttp/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-okhttp diff --git a/pom.xml b/pom.xml index e3497f91f..bb1c94145 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 pom Feign (Parent) diff --git a/reactive/pom.xml b/reactive/pom.xml index f22ef9272..1af232234 100644 --- a/reactive/pom.xml +++ b/reactive/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-reactive-wrappers diff --git a/ribbon/pom.xml b/ribbon/pom.xml index 7fb261e1c..5809ba239 100644 --- a/ribbon/pom.xml +++ b/ribbon/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-ribbon diff --git a/sax/pom.xml b/sax/pom.xml index 131ba32b8..e81cd4010 100644 --- a/sax/pom.xml +++ b/sax/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-sax diff --git a/slf4j/pom.xml b/slf4j/pom.xml index 3e5a8befb..c638e3e08 100644 --- a/slf4j/pom.xml +++ b/slf4j/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-slf4j diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml index cfb055cf7..614020023 100644 --- a/soap-jakarta/pom.xml +++ b/soap-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-soap-jakarta diff --git a/soap/pom.xml b/soap/pom.xml index 2ea09d44c..290923b6d 100644 --- a/soap/pom.xml +++ b/soap/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-soap diff --git a/spring/pom.xml b/spring/pom.xml index 359db0a44..f0b075535 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-spring diff --git a/spring4/pom.xml b/spring4/pom.xml index 27f7358c9..65fcae75d 100644 --- a/spring4/pom.xml +++ b/spring4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-spring4 diff --git a/validation-jakarta/pom.xml b/validation-jakarta/pom.xml index 2a34260bc..6c53bd3e7 100644 --- a/validation-jakarta/pom.xml +++ b/validation-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-validation-jakarta diff --git a/validation/pom.xml b/validation/pom.xml index 65ca7898b..867a10563 100644 --- a/validation/pom.xml +++ b/validation/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-validation diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index 9e4323e8e..b6a816ac5 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14-SNAPSHOT + 13.14 feign-vertx diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 0b13e3f8f..849f50095 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14-SNAPSHOT + 13.14 feign-vertx4-test diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index 6f3820038..332a71c76 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14-SNAPSHOT + 13.14 feign-vertx5-test diff --git a/vertx/pom.xml b/vertx/pom.xml index fbee5079b..637df0312 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14-SNAPSHOT + 13.14 feign-vertx-parent From f6a02deef9449b1454de1f089edcdb353a605e02 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Wed, 19 Aug 2026 11:37:55 -0300 Subject: [PATCH 40/45] [ci skip] updating versions to next development iteration 13.15-SNAPSHOT --- annotation-error-decoder/pom.xml | 2 +- apt-test-generator/pom.xml | 2 +- benchmark/pom.xml | 2 +- core/pom.xml | 2 +- dropwizard-metrics4/pom.xml | 2 +- dropwizard-metrics5/pom.xml | 2 +- example-github-with-coroutine/pom.xml | 2 +- example-github/pom.xml | 2 +- example-wikipedia-with-springboot/pom.xml | 2 +- example-wikipedia/pom.xml | 2 +- fastjson2/pom.xml | 2 +- feign-bom/pom.xml | 90 +++++++++++------------ form-spring/pom.xml | 2 +- form/pom.xml | 2 +- googlehttpclient/pom.xml | 2 +- graphql-apt/pom.xml | 2 +- graphql/pom.xml | 2 +- gson/pom.xml | 2 +- hc5/pom.xml | 2 +- http-cache/pom.xml | 2 +- httpclient/pom.xml | 2 +- hystrix/pom.xml | 2 +- jackson-jaxb/pom.xml | 2 +- jackson-jr/pom.xml | 2 +- jackson/pom.xml | 2 +- jackson3/pom.xml | 2 +- jakarta/pom.xml | 2 +- java11/pom.xml | 2 +- jaxb-jakarta/pom.xml | 2 +- jaxb/pom.xml | 2 +- jaxrs/pom.xml | 2 +- jaxrs2/pom.xml | 2 +- jaxrs3/pom.xml | 2 +- jaxrs4/pom.xml | 2 +- json/pom.xml | 2 +- kotlin/pom.xml | 2 +- micrometer/pom.xml | 2 +- mock/pom.xml | 2 +- moshi/pom.xml | 2 +- okhttp/pom.xml | 2 +- pom.xml | 2 +- reactive/pom.xml | 2 +- ribbon/pom.xml | 2 +- sax/pom.xml | 2 +- slf4j/pom.xml | 2 +- soap-jakarta/pom.xml | 2 +- soap/pom.xml | 2 +- spring/pom.xml | 2 +- spring4/pom.xml | 2 +- validation-jakarta/pom.xml | 2 +- validation/pom.xml | 2 +- vertx/feign-vertx/pom.xml | 2 +- vertx/feign-vertx4-test/pom.xml | 2 +- vertx/feign-vertx5-test/pom.xml | 2 +- vertx/pom.xml | 2 +- 55 files changed, 99 insertions(+), 99 deletions(-) diff --git a/annotation-error-decoder/pom.xml b/annotation-error-decoder/pom.xml index fcbcadcfa..56c6183f9 100644 --- a/annotation-error-decoder/pom.xml +++ b/annotation-error-decoder/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-annotation-error-decoder diff --git a/apt-test-generator/pom.xml b/apt-test-generator/pom.xml index f93b972e0..f6851259c 100644 --- a/apt-test-generator/pom.xml +++ b/apt-test-generator/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT io.github.openfeign.experimental diff --git a/benchmark/pom.xml b/benchmark/pom.xml index d6c015f77..acb226b01 100644 --- a/benchmark/pom.xml +++ b/benchmark/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-benchmark diff --git a/core/pom.xml b/core/pom.xml index eea20f9de..8d83effef 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-core diff --git a/dropwizard-metrics4/pom.xml b/dropwizard-metrics4/pom.xml index 791529fe0..e475a7647 100644 --- a/dropwizard-metrics4/pom.xml +++ b/dropwizard-metrics4/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-dropwizard-metrics4 Feign Dropwizard Metrics4 diff --git a/dropwizard-metrics5/pom.xml b/dropwizard-metrics5/pom.xml index 6a825a4fc..d9b55e549 100644 --- a/dropwizard-metrics5/pom.xml +++ b/dropwizard-metrics5/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-dropwizard-metrics5 Feign Dropwizard Metrics5 diff --git a/example-github-with-coroutine/pom.xml b/example-github-with-coroutine/pom.xml index d75216507..5107e6ab6 100644 --- a/example-github-with-coroutine/pom.xml +++ b/example-github-with-coroutine/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-example-github-with-coroutine diff --git a/example-github/pom.xml b/example-github/pom.xml index 2f18b7f9c..ecc32d0eb 100644 --- a/example-github/pom.xml +++ b/example-github/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-example-github diff --git a/example-wikipedia-with-springboot/pom.xml b/example-wikipedia-with-springboot/pom.xml index 1e2e8279d..5a65c85a8 100644 --- a/example-wikipedia-with-springboot/pom.xml +++ b/example-wikipedia-with-springboot/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-example-wikipedia-with-springboot diff --git a/example-wikipedia/pom.xml b/example-wikipedia/pom.xml index 93627e0fb..4edeacb33 100644 --- a/example-wikipedia/pom.xml +++ b/example-wikipedia/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT io.github.openfeign diff --git a/fastjson2/pom.xml b/fastjson2/pom.xml index 86d1b9045..313251c52 100644 --- a/fastjson2/pom.xml +++ b/fastjson2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-fastjson2 diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index 89dfa446f..da1e30302 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -28,7 +28,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT ../pom.xml @@ -42,222 +42,222 @@ io.github.openfeign feign-core - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-gson - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-http-cache - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxrs - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-httpclient - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxrs2 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-hc5 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-hystrix - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jackson - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jackson3 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jackson-jaxb - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jackson-jr - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxb - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxb-jakarta - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxrs3 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jaxrs4 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-java11 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-jakarta - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-mock - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-json - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-okhttp - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-googlehttpclient - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-ribbon - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-sax - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-slf4j - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-spring - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-soap - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-soap-jakarta - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-reactive-wrappers - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-micrometer - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-dropwizard-metrics4 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-dropwizard-metrics5 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-kotlin - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-graphql - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-annotation-error-decoder - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-form - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-form-spring - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-moshi - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-fastjson2 - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-validation - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-validation-jakarta - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-vertx - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-vertx4-test - 13.14 + 13.15-SNAPSHOT io.github.openfeign feign-vertx5-test - 13.14 + 13.15-SNAPSHOT diff --git a/form-spring/pom.xml b/form-spring/pom.xml index 50bdd6192..4c3ee4d38 100644 --- a/form-spring/pom.xml +++ b/form-spring/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-form-spring diff --git a/form/pom.xml b/form/pom.xml index 54635dcd5..835c77b7f 100644 --- a/form/pom.xml +++ b/form/pom.xml @@ -23,7 +23,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-form diff --git a/googlehttpclient/pom.xml b/googlehttpclient/pom.xml index 8dd3e104d..6a5c9f96b 100644 --- a/googlehttpclient/pom.xml +++ b/googlehttpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-googlehttpclient diff --git a/graphql-apt/pom.xml b/graphql-apt/pom.xml index d0cf50993..0320bc7d3 100644 --- a/graphql-apt/pom.xml +++ b/graphql-apt/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT io.github.openfeign.experimental diff --git a/graphql/pom.xml b/graphql/pom.xml index 6e55d2452..2fe6ee32d 100644 --- a/graphql/pom.xml +++ b/graphql/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-graphql diff --git a/gson/pom.xml b/gson/pom.xml index f8fee04e7..4b78934af 100644 --- a/gson/pom.xml +++ b/gson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-gson diff --git a/hc5/pom.xml b/hc5/pom.xml index 3275d3207..5a580e553 100644 --- a/hc5/pom.xml +++ b/hc5/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-hc5 diff --git a/http-cache/pom.xml b/http-cache/pom.xml index 06cb58681..fdcf77fb7 100644 --- a/http-cache/pom.xml +++ b/http-cache/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-http-cache diff --git a/httpclient/pom.xml b/httpclient/pom.xml index c474c56f9..537e385c3 100644 --- a/httpclient/pom.xml +++ b/httpclient/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-httpclient diff --git a/hystrix/pom.xml b/hystrix/pom.xml index 30bb642f8..4b018f79d 100644 --- a/hystrix/pom.xml +++ b/hystrix/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-hystrix diff --git a/jackson-jaxb/pom.xml b/jackson-jaxb/pom.xml index b2727ff00..e74cc4a2a 100644 --- a/jackson-jaxb/pom.xml +++ b/jackson-jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jackson-jaxb diff --git a/jackson-jr/pom.xml b/jackson-jr/pom.xml index 5bb6036a9..7dc2e2f3e 100644 --- a/jackson-jr/pom.xml +++ b/jackson-jr/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jackson-jr diff --git a/jackson/pom.xml b/jackson/pom.xml index 2378b46c3..dc7cb2336 100644 --- a/jackson/pom.xml +++ b/jackson/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jackson diff --git a/jackson3/pom.xml b/jackson3/pom.xml index b79498533..b1bf73ca7 100644 --- a/jackson3/pom.xml +++ b/jackson3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jackson3 diff --git a/jakarta/pom.xml b/jakarta/pom.xml index 8c314ba7b..2a4f584c2 100644 --- a/jakarta/pom.xml +++ b/jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jakarta diff --git a/java11/pom.xml b/java11/pom.xml index 990c17603..4853f03a3 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-java11 diff --git a/jaxb-jakarta/pom.xml b/jaxb-jakarta/pom.xml index 1f5508f8b..7a31937d3 100644 --- a/jaxb-jakarta/pom.xml +++ b/jaxb-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxb-jakarta diff --git a/jaxb/pom.xml b/jaxb/pom.xml index 9473704c6..dacf2446f 100644 --- a/jaxb/pom.xml +++ b/jaxb/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxb diff --git a/jaxrs/pom.xml b/jaxrs/pom.xml index 140394765..3290441d9 100644 --- a/jaxrs/pom.xml +++ b/jaxrs/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxrs diff --git a/jaxrs2/pom.xml b/jaxrs2/pom.xml index 0317adefd..ae9ab90eb 100644 --- a/jaxrs2/pom.xml +++ b/jaxrs2/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxrs2 diff --git a/jaxrs3/pom.xml b/jaxrs3/pom.xml index 1f70cc34b..f1e1501a5 100644 --- a/jaxrs3/pom.xml +++ b/jaxrs3/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxrs3 diff --git a/jaxrs4/pom.xml b/jaxrs4/pom.xml index a3ef12c8a..1dbdca636 100644 --- a/jaxrs4/pom.xml +++ b/jaxrs4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-jaxrs4 diff --git a/json/pom.xml b/json/pom.xml index 407e4d2a0..040330a77 100644 --- a/json/pom.xml +++ b/json/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-json diff --git a/kotlin/pom.xml b/kotlin/pom.xml index c192d4dc9..25bef2e36 100644 --- a/kotlin/pom.xml +++ b/kotlin/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-kotlin diff --git a/micrometer/pom.xml b/micrometer/pom.xml index de7fc7bd8..24e4fa4dc 100644 --- a/micrometer/pom.xml +++ b/micrometer/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-micrometer Feign Micrometer diff --git a/mock/pom.xml b/mock/pom.xml index af245289f..cb2e8b2b8 100644 --- a/mock/pom.xml +++ b/mock/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-mock diff --git a/moshi/pom.xml b/moshi/pom.xml index 2702c1796..13b0a58b9 100644 --- a/moshi/pom.xml +++ b/moshi/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-moshi diff --git a/okhttp/pom.xml b/okhttp/pom.xml index 965c407f7..1187d93a8 100644 --- a/okhttp/pom.xml +++ b/okhttp/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-okhttp diff --git a/pom.xml b/pom.xml index bb1c94145..a277965d9 100644 --- a/pom.xml +++ b/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT pom Feign (Parent) diff --git a/reactive/pom.xml b/reactive/pom.xml index 1af232234..1c91a5413 100644 --- a/reactive/pom.xml +++ b/reactive/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-reactive-wrappers diff --git a/ribbon/pom.xml b/ribbon/pom.xml index 5809ba239..be74d2200 100644 --- a/ribbon/pom.xml +++ b/ribbon/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-ribbon diff --git a/sax/pom.xml b/sax/pom.xml index e81cd4010..023e030ca 100644 --- a/sax/pom.xml +++ b/sax/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-sax diff --git a/slf4j/pom.xml b/slf4j/pom.xml index c638e3e08..3aa591db7 100644 --- a/slf4j/pom.xml +++ b/slf4j/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-slf4j diff --git a/soap-jakarta/pom.xml b/soap-jakarta/pom.xml index 614020023..1bf8c51d7 100644 --- a/soap-jakarta/pom.xml +++ b/soap-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-soap-jakarta diff --git a/soap/pom.xml b/soap/pom.xml index 290923b6d..db7f32b22 100644 --- a/soap/pom.xml +++ b/soap/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-soap diff --git a/spring/pom.xml b/spring/pom.xml index f0b075535..1e27abfa6 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-spring diff --git a/spring4/pom.xml b/spring4/pom.xml index 65fcae75d..c99930579 100644 --- a/spring4/pom.xml +++ b/spring4/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-spring4 diff --git a/validation-jakarta/pom.xml b/validation-jakarta/pom.xml index 6c53bd3e7..a41f4abb8 100644 --- a/validation-jakarta/pom.xml +++ b/validation-jakarta/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-validation-jakarta diff --git a/validation/pom.xml b/validation/pom.xml index 867a10563..a39dca7c6 100644 --- a/validation/pom.xml +++ b/validation/pom.xml @@ -22,7 +22,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-validation diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index b6a816ac5..0cffe0fd9 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14 + 13.15-SNAPSHOT feign-vertx diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 849f50095..1b7fd996f 100644 --- a/vertx/feign-vertx4-test/pom.xml +++ b/vertx/feign-vertx4-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14 + 13.15-SNAPSHOT feign-vertx4-test diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index 332a71c76..05b035ef6 100644 --- a/vertx/feign-vertx5-test/pom.xml +++ b/vertx/feign-vertx5-test/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-vertx-parent - 13.14 + 13.15-SNAPSHOT feign-vertx5-test diff --git a/vertx/pom.xml b/vertx/pom.xml index 637df0312..f216efe38 100644 --- a/vertx/pom.xml +++ b/vertx/pom.xml @@ -21,7 +21,7 @@ io.github.openfeign feign-parent - 13.14 + 13.15-SNAPSHOT feign-vertx-parent From 3426cb3cdb4a1320734ad81bd5d1f856a1dc26da Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:12:53 +0000 Subject: [PATCH 41/45] build(deps): Bump jackson.version from 2.22.1 to 2.22.2 Bumps `jackson.version` from 2.22.1 to 2.22.2. Updates `com.fasterxml.jackson:jackson-bom` from 2.22.1 to 2.22.2 - [Commits](https://github.com/FasterXML/jackson-bom/compare/jackson-bom-2.22.1...jackson-bom-2.22.2) Updates `com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider` from 2.22.1 to 2.22.2 --- updated-dependencies: - dependency-name: com.fasterxml.jackson:jackson-bom dependency-version: 2.22.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider dependency-version: 2.22.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- vertx/feign-vertx/pom.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a277965d9..a645007fa 100644 --- a/pom.xml +++ b/pom.xml @@ -174,7 +174,7 @@ 4.1.0 6.1.3 - 2.22.1 + 2.22.2 3.2.2 3.27.7 5.23.0 diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index 0cffe0fd9..297ee0f7d 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -31,7 +31,7 @@ 11 - 2.22.1 + 2.22.2 From 9433cab9c2312acce51ac21e3a2de4cddb9acc8f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:13:09 +0000 Subject: [PATCH 42/45] build(deps): Bump com.squareup.okhttp3:okhttp-bom from 5.4.0 to 5.5.0 Bumps [com.squareup.okhttp3:okhttp-bom](https://github.com/lysine-dev/okhttp) from 5.4.0 to 5.5.0. - [Changelog](https://github.com/lysine-dev/okhttp/blob/main/CHANGELOG.md) - [Commits](https://github.com/lysine-dev/okhttp/compare/parent-5.4.0...parent-5.5.0) --- updated-dependencies: - dependency-name: com.squareup.okhttp3:okhttp-bom dependency-version: 5.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a277965d9..89f41eab6 100644 --- a/pom.xml +++ b/pom.xml @@ -164,7 +164,7 @@ ${main.java.version} ${main.java.version} - 5.4.0 + 5.5.0 33.7.1-jre 2.2.0 2.14.0 From e012d67589c204d965eb6bb04220942d920e6479 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Thu, 20 Aug 2026 12:12:06 -0300 Subject: [PATCH 43/45] Drop the multi-encoder default encoder in favour of an explicit any() predicate Signed-off-by: Marvin Froeder --- CHANGELOG.md | 14 +- README.md | 89 ++++++++-- core/src/main/java/feign/BaseBuilder.java | 20 ++- .../java/feign/codec/EncoderPredicate.java | 73 ++++++-- .../main/java/feign/codec/MultiEncoder.java | 120 +++++++------ .../main/java/feign/codec/PairedEncoder.java | 77 +++++++++ .../java/feign/codec/PredicatedEncoder.java | 65 ++++++- .../feign/codec/EncoderPredicateTest.java | 33 ++++ .../codec/MultiEncoderCapabilityTest.java | 65 ++++++- .../java/feign/codec/MultiEncoderTest.java | 163 +++++++++++++----- .../feign/form/spring/SpringFormEncoder.java | 15 +- .../src/main/java/feign/form/FormEncoder.java | 54 +++++- .../feign/form/PredicatedFormEncoderTest.java | 104 +++++++++++ 13 files changed, 730 insertions(+), 162 deletions(-) create mode 100644 core/src/main/java/feign/codec/PairedEncoder.java create mode 100644 form/src/test/java/feign/form/PredicatedFormEncoderTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 804504da5..3a2b48353 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,14 @@ * Add `@Experimental` `MultiEncoder`, `PredicatedEncoder` and `EncoderPredicate`, letting a single client route each request to the right encoder. Encoders declare what they can handle by implementing `PredicatedEncoder`; anything else is paired with a predicate via - `MultiEncoder.builder(defaultEncoder)`. The first-party JSON encoders (Gson, Jackson, Jackson 3, - Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, - SOAP Jakarta) now declare themselves, and the metrics modules' `MeteredEncoder` forwards - `canEncode` to the encoder it wraps. The `Encoder` interface is unchanged, so existing encoders - keep working (#3485). - + `PredicatedEncoder.of(predicate, encoder)` or `MultiEncoder.builder()`. Encoders are consulted in + the order given and a request nothing accepts fails with an `EncodeException` naming what was + tried, so a default is an encoder guarded by `EncoderPredicate.any()` listed last. `FormEncoder` + and `SpringFormEncoder` gain `createPredicatedFormEncoder()`, a delegate-free flavour that can + take part. The first-party JSON encoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, + Moshi, Fastjson2, JSON-java) and XML encoders (JAXB, JAXB Jakarta, SOAP, SOAP Jakarta) now declare + themselves, and the metrics modules' `MeteredEncoder` forwards `canEncode` to the encoder it + wraps. The `Encoder` interface is unchanged, so existing encoders keep working (#3485). * Add support for the HTTP QUERY method (RFC 10008) — safe, idempotent, and cacheable with a request body. `HttpCacheInterceptor` includes QUERY in its default cacheable set and incorporates a body hash into the cache key to reduce cross-body collisions. diff --git a/README.md b/README.md index 7925caa30..037cbb318 100644 --- a/README.md +++ b/README.md @@ -714,10 +714,11 @@ public class Example { > This API is `@Experimental` and may change incompatibly, or be removed, in a future release. A single client sometimes has to speak more than one format — JSON for most endpoints, XML for -a legacy one, plain bytes for an upload. `MultiEncoder` routes each request to the right encoder, -falling back to a default when none applies. +a legacy one, plain bytes for an upload. `MultiEncoder` hands each request to the first encoder that +accepts it. -Most first-party encoders already declare what they can handle, so they can simply be added: +Most first-party encoders already declare what they can handle, so they can simply be listed, in the +order they should be consulted: ```java interface MixedClient { @@ -733,36 +734,56 @@ interface MixedClient { public class Example { public static void main(String[] args) { MixedClient client = Feign.builder() - .encoder(new DefaultEncoder(), new GsonEncoder(), new JAXBEncoder()) + .encoders(new GsonEncoder(), new JAXBEncoder()) .target(MixedClient.class, "https://foo.com"); } } ``` -The first argument is the default encoder, used when nothing else accepts the request. +There is no implicit fallback. A request that no encoder accepts fails with an `EncodeException` +naming the encoders that were tried and what each one wants: -For an encoder that does not declare itself — including one you do not control — pair it -with an `EncoderPredicate` using the builder: +``` +Unable to encode java.lang.String (Content-Type: text/plain) for POST /orders. Encoders tried, in order: + - GsonEncoder + - JAXBEncoder +Add an encoder guarded by EncoderPredicate.any() last to act as a default. +``` + +To get a default, pair an encoder with the predicate that accepts everything and list it **last**: + +```java +Feign.builder() + .encoders( + new GsonEncoder(), + new JAXBEncoder(), + PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder())); +``` + +The same pairing works for any encoder that does not declare itself, including one you do not +control. `MultiEncoder.builder()` spells it out when a lambda reads better than a wrapper: ```java Encoder encoder = - MultiEncoder.builder(new DefaultEncoder()) - .add(new GsonEncoder()) // declares itself - .add(EncoderPredicate.xmlContentType(), someXmlEncoder) // paired + MultiEncoder.builder() + .add(new GsonEncoder()) // declares itself + .add(EncoderPredicate.xmlContentType(), someXmlEncoder) // paired .add((object, bodyType, template) -> bodyType == byte[].class, binaryEncoder) + .add(EncoderPredicate.any(), new DefaultEncoder()) // the default, last .build(); ``` -Delegates are consulted in the order they were added, so put the narrowest predicate first. Note -that `Content-Type: application/json` with a null body is claimed by a JSON encoder before +Encoders are consulted in the order they were added, so put the narrowest one first. Note that +`Content-Type: application/json` with a null body is claimed by a JSON encoder before `EncoderPredicate.emptyBody()` gets a chance — order accordingly. ##### Declaring your own encoder -Implement `PredicatedEncoder` alongside `Encoder` and override `canEncode`: +Implement `PredicatedEncoder` and say what you handle. `canEncode` has no default: an encoder that +declares nothing would claim every request, which is rarely what its author meant. ```java -public class MyEncoder implements Encoder, PredicatedEncoder { +public class MyEncoder implements PredicatedEncoder { @Override public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { @@ -776,12 +797,42 @@ public class MyEncoder implements Encoder, PredicatedEncoder { } ``` -`EncoderPredicate` ships with `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, -`emptyBody()`, `bodyType(type)` and `formEncoded()`, plus `and`/`or`/`negate` to combine them. +`EncoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with +`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`, +`bodyType(type)` and `formEncoded()`, plus `and`/`or`/`negate` to combine them. Each one describes +itself, which is what shows up in the error message above; wrap your own lambdas in +`EncoderPredicate.describedAs("it is Tuesday", ...)` to read as well. + +`PredicatedEncoder.of(predicate, encoder)` replaces whatever the encoder says about itself, so it +can widen an encoder as well as narrow it. To keep the encoder's own declaration and add to it, use +`narrowing`: + +```java +// only this vendor content type, and only what Gson would have taken anyway +PredicatedEncoder.narrowing( + EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder()); +``` + +**If you wrap an encoder, forward `canEncode` to your delegate**, otherwise wrapping silently +changes what the encoder handles. The metrics modules' `MeteredEncoder` forwards for exactly this +reason. + +##### Form encoders + +`FormEncoder` and `SpringFormEncoder` wrap a delegate encoder, so they cannot honestly declare what +they handle — the delegate's applicability is unknown to them. Instead, each offers a +delegate-free flavour that does: + +```java +Feign.builder() + .encoders( + FormEncoder.createPredicatedFormEncoder(), // form and multipart requests only + new JacksonEncoder()); +``` -**If you wrap an encoder, forward `canEncode` to your delegate.** A wrapper that does not will claim -every request, because the default `canEncode` accepts everything. The metrics modules' -`MeteredEncoder` forwards for exactly this reason. +It accepts form and multipart requests carrying a map or a user pojo, and leaves everything else to +the encoders registered alongside it. Constructing one directly with a `null` delegate does the same +thing: anything it cannot encode itself fails with an `EncodeException` instead of being passed on. ### @Body templates The `@Body` annotation indicates a template to expand using parameters annotated with `@Param`. You will likely need to add a `Content-Type` header. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index 12cbf9c3c..b59888862 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -26,6 +26,7 @@ import feign.codec.DefaultEncoder; import feign.codec.DefaultErrorDecoder; import feign.codec.Encoder; +import feign.codec.EncoderPredicate; import feign.codec.ErrorDecoder; import feign.codec.MultiEncoder; import feign.codec.PredicatedEncoder; @@ -99,23 +100,28 @@ public B encoder(Encoder encoder) { /** * Configures a {@link MultiEncoder} built from encoders that declare their own applicability. * - *

Each {@link PredicatedEncoder} is consulted in the order given; {@code defaultEncoder} is - * the fallback used when none accepts the request. + *

Encoders are consulted in the order given, and the first one that accepts the request + * encodes it. There is no implicit fallback: pair an encoder with {@link EncoderPredicate#any()} + * and list it last to act as a default, otherwise a request nothing accepts fails with an {@link + * feign.codec.EncodeException}. * *

    * Feign.builder()
-   *     .encoder(new DefaultEncoder(), new JacksonEncoder(), new JAXBEncoder())
+   *     .encoders(
+   *         new JacksonEncoder(),
+   *         new JAXBEncoder(),
+   *         PredicatedEncoder.of(EncoderPredicate.any(), new DefaultEncoder()))
    * 
* *

To pair a predicate with an encoder that does not implement {@link PredicatedEncoder}, use - * {@link MultiEncoder#builder(Encoder)} instead. + * {@link PredicatedEncoder#of(EncoderPredicate, Encoder)} as above, or {@link + * MultiEncoder#builder()} for the same thing spelled out. * - * @param defaultEncoder the encoder used when no delegate accepts the request * @param encoders the predicated encoders, consulted in the order given */ @Experimental - public B encoder(Encoder defaultEncoder, PredicatedEncoder... encoders) { - MultiEncoder.Builder builder = MultiEncoder.builder(defaultEncoder); + public B encoders(PredicatedEncoder... encoders) { + MultiEncoder.Builder builder = MultiEncoder.builder(); for (PredicatedEncoder encoder : encoders) { builder.add(encoder); } diff --git a/core/src/main/java/feign/codec/EncoderPredicate.java b/core/src/main/java/feign/codec/EncoderPredicate.java index 1383dae47..150fd22de 100644 --- a/core/src/main/java/feign/codec/EncoderPredicate.java +++ b/core/src/main/java/feign/codec/EncoderPredicate.java @@ -28,6 +28,10 @@ * RequestTemplate)}, so they can discriminate on the body, on its declared type, or on anything * already present in the template such as the {@code Content-Type} header. * + *

Every predicate built here describes itself, so a {@link MultiEncoder} that cannot route a + * request can say what it did consider. Wrap your own lambdas in {@link #describedAs(String, + * EncoderPredicate)} to get the same in error messages. + * * @see PredicatedEncoder * @see MultiEncoder */ @@ -46,14 +50,49 @@ public interface EncoderPredicate { */ boolean canEncode(Object object, Type bodyType, RequestTemplate template); + /** + * Wraps a predicate so that it describes itself, which is what a {@link MultiEncoder} reports + * when no encoder accepts a request. + * + * @param description how the predicate reads in an error message, for example {@code + * "Content-Type is JSON"} + * @param predicate the predicate to describe + */ + static EncoderPredicate describedAs(String description, EncoderPredicate predicate) { + Objects.requireNonNull(description, "description cannot be null"); + Objects.requireNonNull(predicate, "predicate cannot be null"); + return new EncoderPredicate() { + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return predicate.canEncode(object, bodyType, template); + } + + @Override + public String toString() { + return description; + } + }; + } + + /** + * Matches every request. Pair this with an encoder registered last to make it the default of a + * {@link MultiEncoder}. + */ + static EncoderPredicate any() { + return describedAs("any request", (object, bodyType, template) -> true); + } + /** Matches requests whose {@code Content-Type} header denotes JSON. */ static EncoderPredicate jsonContentType() { - return (object, bodyType, template) -> Util.isJsonContentType(template); + return describedAs( + "Content-Type is JSON", (object, bodyType, template) -> Util.isJsonContentType(template)); } /** Matches requests whose {@code Content-Type} header denotes XML. */ static EncoderPredicate xmlContentType() { - return (object, bodyType, template) -> Util.isXmlContentType(template); + return describedAs( + "Content-Type is XML", (object, bodyType, template) -> Util.isXmlContentType(template)); } /** @@ -62,38 +101,50 @@ static EncoderPredicate xmlContentType() { */ static EncoderPredicate contentType(String mediaType) { Objects.requireNonNull(mediaType, "mediaType cannot be null"); - return (object, bodyType, template) -> Util.hasContentType(template, mediaType); + return describedAs( + "Content-Type is " + mediaType, + (object, bodyType, template) -> Util.hasContentType(template, mediaType)); } /** Matches requests carrying no body. */ static EncoderPredicate emptyBody() { - return (object, bodyType, template) -> object == null; + return describedAs("body is empty", (object, bodyType, template) -> object == null); } /** Matches requests whose declared body type is exactly the given type. */ static EncoderPredicate bodyType(Type type) { Objects.requireNonNull(type, "type cannot be null"); - return (object, bodyType, template) -> type.equals(bodyType); + return describedAs( + "body type is " + type.getTypeName(), + (object, bodyType, template) -> type.equals(bodyType)); } /** Matches form-encoded requests, as signalled by {@link Encoder#MAP_STRING_WILDCARD}. */ static EncoderPredicate formEncoded() { - return (object, bodyType, template) -> Encoder.MAP_STRING_WILDCARD.equals(bodyType); + return describedAs( + "body is form encoded", + (object, bodyType, template) -> Encoder.MAP_STRING_WILDCARD.equals(bodyType)); } default EncoderPredicate and(EncoderPredicate other) { Objects.requireNonNull(other, "other cannot be null"); - return (object, bodyType, template) -> - canEncode(object, bodyType, template) && other.canEncode(object, bodyType, template); + return describedAs( + "(" + this + " and " + other + ")", + (object, bodyType, template) -> + canEncode(object, bodyType, template) && other.canEncode(object, bodyType, template)); } default EncoderPredicate or(EncoderPredicate other) { Objects.requireNonNull(other, "other cannot be null"); - return (object, bodyType, template) -> - canEncode(object, bodyType, template) || other.canEncode(object, bodyType, template); + return describedAs( + "(" + this + " or " + other + ")", + (object, bodyType, template) -> + canEncode(object, bodyType, template) || other.canEncode(object, bodyType, template)); } default EncoderPredicate negate() { - return (object, bodyType, template) -> !canEncode(object, bodyType, template); + return describedAs( + "not (" + this + ")", + (object, bodyType, template) -> !canEncode(object, bodyType, template)); } } diff --git a/core/src/main/java/feign/codec/MultiEncoder.java b/core/src/main/java/feign/codec/MultiEncoder.java index c2ab15e8e..23feaf35b 100644 --- a/core/src/main/java/feign/codec/MultiEncoder.java +++ b/core/src/main/java/feign/codec/MultiEncoder.java @@ -17,32 +17,38 @@ import feign.Experimental; import feign.RequestTemplate; +import feign.Util; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; /** - * An {@link Encoder} that selects a delegate per request, falling back to a default encoder when no - * delegate accepts it. + * An {@link Encoder} that hands each request to the first encoder that accepts it. * - *

Delegates come from two places. An encoder that implements {@link PredicatedEncoder} declares + *

Encoders come from two places. An encoder that implements {@link PredicatedEncoder} declares * its own applicability and can simply be added; any other encoder is paired with an {@link * EncoderPredicate} at the call site: * *

  * Feign.builder()
  *     .encoder(
- *         MultiEncoder.builder(new DefaultEncoder())
+ *         MultiEncoder.builder()
  *             .add(new JacksonEncoder())
  *             .add(EncoderPredicate.xmlContentType(), new JAXBEncoder())
  *             .add((object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder())
+ *             .add(EncoderPredicate.any(), new DefaultEncoder())
  *             .build());
  * 
* - *

Delegates are consulted in the order they were added, so the narrowest predicate should come - * first. The default encoder is consulted last. + *

Encoders are consulted in the order they were added, so the narrowest one comes first. There + * is no implicit fallback: a request no encoder accepts fails with an {@link EncodeException} + * naming what was tried. Add an encoder guarded by {@link EncoderPredicate#any()} last to act as a + * default, as above. * * @see PredicatedEncoder * @see EncoderPredicate @@ -50,76 +56,83 @@ @Experimental public class MultiEncoder implements Encoder { - private final Encoder defaultEncoder; + private final List encoders; - private final List delegates; - - private MultiEncoder(Encoder defaultEncoder, List delegates) { - this.defaultEncoder = defaultEncoder; - this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); + private MultiEncoder(List encoders) { + this.encoders = Collections.unmodifiableList(new ArrayList<>(encoders)); } - /** - * Starts building a multi-encoder. - * - * @param defaultEncoder the encoder used when no delegate accepts the request - * @return the builder - */ - public static Builder builder(Encoder defaultEncoder) { - return new Builder(defaultEncoder); + /** Starts building a multi-encoder. */ + public static Builder builder() { + return new Builder(); } /** - * Encodes using the first delegate that accepts the request, or the default encoder if none do. + * Encodes using the first encoder that accepts the request. * * @param object {@inheritDoc} * @param bodyType {@inheritDoc} * @param template {@inheritDoc} - * @throws EncodeException {@inheritDoc} + * @throws EncodeException when no encoder accepts the request, or the chosen one fails */ @Override public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { - for (Delegate delegate : delegates) { - if (delegate.predicate.canEncode(object, bodyType, template)) { - delegate.encoder.encode(object, bodyType, template); + for (PredicatedEncoder encoder : encoders) { + if (encoder.canEncode(object, bodyType, template)) { + encoder.encode(object, bodyType, template); return; } } - defaultEncoder.encode(object, bodyType, template); + throw new EncodeException(unableToEncode(bodyType, template)); } - @Override - public String toString() { - return "MultiEncoder{defaultEncoder=" + defaultEncoder + ", delegates=" + delegates + '}'; + private String unableToEncode(Type bodyType, RequestTemplate template) { + StringBuilder message = + new StringBuilder("Unable to encode ") + .append(bodyType == null ? "request body" : bodyType.getTypeName()) + .append(" (Content-Type: ") + .append(contentTypes(template)) + .append(')'); + if (template.method() != null) { + message.append(" for ").append(template.method()).append(' ').append(template.path()); + } + if (encoders.isEmpty()) { + return message.append(". No encoders were configured.").toString(); + } + message.append(". Encoders tried, in order:"); + for (PredicatedEncoder encoder : encoders) { + message.append("\n - ").append(PairedEncoder.describe(encoder)); + } + return message + .append("\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default.") + .toString(); } - private static final class Delegate { - private final EncoderPredicate predicate; - private final Encoder encoder; - - Delegate(EncoderPredicate predicate, Encoder encoder) { - this.predicate = predicate; - this.encoder = encoder; - } + private static String contentTypes(RequestTemplate template) { + String contentTypes = + template.headers().entrySet().stream() + .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .collect(Collectors.joining(", ")); + return contentTypes.isEmpty() ? "not set" : contentTypes; + } - @Override - public String toString() { - return encoder.toString(); - } + @Override + public String toString() { + return "MultiEncoder" + + encoders.stream().map(PairedEncoder::describe).collect(Collectors.toList()); } - /** Collects the delegates of a {@link MultiEncoder}. */ + /** Collects the encoders of a {@link MultiEncoder}. */ @Experimental public static final class Builder { - private final Encoder defaultEncoder; + private final List encoders = new ArrayList<>(); - private final List delegates = new ArrayList<>(); - - private Builder(Encoder defaultEncoder) { - this.defaultEncoder = Objects.requireNonNull(defaultEncoder, "defaultEncoder cannot be null"); - } + private Builder() {} /** * Adds an encoder that declares its own applicability. @@ -127,8 +140,8 @@ private Builder(Encoder defaultEncoder) { * @param encoder the encoder, consulted via {@link PredicatedEncoder#canEncode} */ public Builder add(PredicatedEncoder encoder) { - Objects.requireNonNull(encoder, "encoder cannot be null"); - return add(encoder::canEncode, encoder); + encoders.add(Objects.requireNonNull(encoder, "encoder cannot be null")); + return this; } /** @@ -139,15 +152,12 @@ public Builder add(PredicatedEncoder encoder) { * @param encoder the encoder to delegate to */ public Builder add(EncoderPredicate predicate, Encoder encoder) { - Objects.requireNonNull(predicate, "predicate cannot be null"); - Objects.requireNonNull(encoder, "encoder cannot be null"); - delegates.add(new Delegate(predicate, encoder)); - return this; + return add(PredicatedEncoder.of(predicate, encoder)); } /** Builds the multi-encoder. */ public MultiEncoder build() { - return new MultiEncoder(defaultEncoder, delegates); + return new MultiEncoder(encoders); } } } diff --git a/core/src/main/java/feign/codec/PairedEncoder.java b/core/src/main/java/feign/codec/PairedEncoder.java new file mode 100644 index 000000000..61a63f763 --- /dev/null +++ b/core/src/main/java/feign/codec/PairedEncoder.java @@ -0,0 +1,77 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.RequestTemplate; +import java.lang.reflect.Type; +import java.util.Objects; + +/** An encoder that does not declare itself, guarded by a predicate supplied at the call site. */ +final class PairedEncoder implements PredicatedEncoder { + + private final EncoderPredicate predicate; + + private final Encoder encoder; + + PairedEncoder(EncoderPredicate predicate, Encoder encoder) { + this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null"); + this.encoder = Objects.requireNonNull(encoder, "encoder cannot be null"); + } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return predicate.canEncode(object, bodyType, template); + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) + throws EncodeException { + encoder.encode(object, bodyType, template); + } + + @Override + public String toString() { + return describe(encoder) + " when " + predicate; + } + + /** Requires both the predicate and, when the encoder declares one, its own applicability. */ + static EncoderPredicate narrow(EncoderPredicate predicate, Encoder encoder) { + Objects.requireNonNull(predicate, "predicate cannot be null"); + Objects.requireNonNull(encoder, "encoder cannot be null"); + if (!(encoder instanceof PredicatedEncoder)) { + return predicate; + } + if (encoder instanceof PairedEncoder) { + return predicate.and(((PairedEncoder) encoder).predicate); + } + PredicatedEncoder predicated = (PredicatedEncoder) encoder; + return predicate.and( + EncoderPredicate.describedAs(describe(encoder) + " accepts it", predicated::canEncode)); + } + + /** The encoder's own {@code toString} when it has one, its class name otherwise. */ + static String describe(Encoder encoder) { + Class type = encoder.getClass(); + try { + if (type.getMethod("toString").getDeclaringClass() != Object.class) { + return encoder.toString(); + } + } catch (NoSuchMethodException ignored) { + // cannot happen, every class has toString + } + return type.getSimpleName().isEmpty() ? type.getName() : type.getSimpleName(); + } +} diff --git a/core/src/main/java/feign/codec/PredicatedEncoder.java b/core/src/main/java/feign/codec/PredicatedEncoder.java index c97cfd072..f9cd135ac 100644 --- a/core/src/main/java/feign/codec/PredicatedEncoder.java +++ b/core/src/main/java/feign/codec/PredicatedEncoder.java @@ -26,17 +26,25 @@ * route each request to the right one without the call site having to wrap anything: * *

- * public class JacksonEncoder implements Encoder, PredicatedEncoder {
+ * public class JacksonEncoder implements PredicatedEncoder {
  *
  *   @Override
  *   public boolean canEncode(Object object, Type bodyType, RequestTemplate template) {
- *     return EncoderPredicate.jsonContentType().canEncode(object, bodyType, template);
+ *     return Util.isJsonContentType(template);
+ *   }
+ *
+ *   @Override
+ *   public void encode(Object object, Type bodyType, RequestTemplate template) {
+ *     // ...
  *   }
  * }
  * 
* - *

{@link Encoder#encode(Object, Type, RequestTemplate) encode} remains the only abstract method, - * so this stays a functional interface and a bare lambda is an encoder that accepts everything. + *

{@code canEncode} is deliberately abstract: an encoder that says nothing about what it handles + * would claim every request, which is almost never what its author meant. Use {@link + * #of(EncoderPredicate, Encoder)} to give an existing encoder a predicate instead of implementing + * this on it, and {@link EncoderPredicate} — which is a {@code @FunctionalInterface} — + * to write that predicate as a lambda. * *

Encoders that wrap another encoder should forward {@code canEncode} to their delegate, so that * wrapping does not discard the delegate's applicability. @@ -45,11 +53,52 @@ * @see EncoderPredicate */ @Experimental -@FunctionalInterface public interface PredicatedEncoder extends Encoder { /** - * Whether this encoder can handle the request. Defaults to accepting everything. + * Pairs any encoder with a predicate, for encoders that do not declare themselves, including ones + * you do not control. The predicate is the whole answer: whatever the encoder may declare about + * itself is replaced, so this can widen an encoder as well as narrow it. Use {@link + * #narrowing(EncoderPredicate, Encoder)} to keep the encoder's own declaration. + * + *

An encoder paired with {@link EncoderPredicate#any()} accepts everything, which is how a + * {@link MultiEncoder} is given a default: + * + *

+   * Feign.builder()
+   *     .encoders(
+   *         new JacksonEncoder(),
+   *         PredicatedEncoder.of(EncoderPredicate.any(), new Encoder.Default()));
+   * 
+ * + * @param predicate decides whether the encoder handles a request + * @param encoder the encoder to delegate to + */ + static PredicatedEncoder of(EncoderPredicate predicate, Encoder encoder) { + return new PairedEncoder(predicate, encoder); + } + + /** + * Narrows an encoder that already declares itself, by requiring both the given predicate and the + * encoder's own {@code canEncode} to accept the request: + * + *
+   * PredicatedEncoder.narrowing(
+   *     EncoderPredicate.contentType("application/vnd.acme+json"), new GsonEncoder());
+   * 
+ * + *

An encoder that does not implement {@link PredicatedEncoder} declares nothing to narrow, so + * this behaves like {@link #of(EncoderPredicate, Encoder)}. + * + * @param predicate narrows what the encoder handles + * @param encoder the encoder to delegate to + */ + static PredicatedEncoder narrowing(EncoderPredicate predicate, Encoder encoder) { + return new PairedEncoder(PairedEncoder.narrow(predicate, encoder), encoder); + } + + /** + * Whether this encoder can handle the request. * * @param object what to encode as the request body * @param bodyType the type the object should be encoded as. {@link Encoder#MAP_STRING_WILDCARD} @@ -57,7 +106,5 @@ public interface PredicatedEncoder extends Encoder { * @param template the request template to populate * @return {@code true} if this encoder can encode the request, {@code false} otherwise */ - default boolean canEncode(Object object, Type bodyType, RequestTemplate template) { - return true; - } + boolean canEncode(Object object, Type bodyType, RequestTemplate template); } diff --git a/core/src/test/java/feign/codec/EncoderPredicateTest.java b/core/src/test/java/feign/codec/EncoderPredicateTest.java index 899085d9f..5028567d6 100644 --- a/core/src/test/java/feign/codec/EncoderPredicateTest.java +++ b/core/src/test/java/feign/codec/EncoderPredicateTest.java @@ -34,6 +34,15 @@ private static boolean test(EncoderPredicate predicate, String contentType) { return predicate.canEncode("body", String.class, template(contentType)); } + @Test + void anyMatchesEverything() { + EncoderPredicate any = EncoderPredicate.any(); + + assertThat(test(any, "application/json")).isTrue(); + assertThat(test(any, null)).isTrue(); + assertThat(any.canEncode(null, null, template(null))).isTrue(); + } + @Test void jsonContentTypeMatchesJsonOnly() { EncoderPredicate json = EncoderPredicate.jsonContentType(); @@ -101,6 +110,30 @@ void formEncodedMatchesTheFormBodyTypeMarker() { assertThat(form.canEncode("body", String.class, template(null))).isFalse(); } + @Test + void predicatesDescribeThemselves() { + assertThat(EncoderPredicate.any()).hasToString("any request"); + assertThat(EncoderPredicate.jsonContentType()).hasToString("Content-Type is JSON"); + assertThat(EncoderPredicate.xmlContentType()).hasToString("Content-Type is XML"); + assertThat(EncoderPredicate.contentType("text/plain")) + .hasToString("Content-Type is text/plain"); + assertThat(EncoderPredicate.emptyBody()).hasToString("body is empty"); + assertThat(EncoderPredicate.bodyType(byte[].class)).hasToString("body type is byte[]"); + assertThat(EncoderPredicate.formEncoded()).hasToString("body is form encoded"); + assertThat(EncoderPredicate.describedAs("it is Tuesday", (o, b, t) -> true)) + .hasToString("it is Tuesday"); + } + + @Test + void combinedPredicatesDescribeThemselves() { + EncoderPredicate json = EncoderPredicate.jsonContentType(); + EncoderPredicate xml = EncoderPredicate.xmlContentType(); + + assertThat(json.or(xml)).hasToString("(Content-Type is JSON or Content-Type is XML)"); + assertThat(json.and(xml)).hasToString("(Content-Type is JSON and Content-Type is XML)"); + assertThat(json.negate()).hasToString("not (Content-Type is JSON)"); + } + @Test void combinators() { EncoderPredicate json = EncoderPredicate.jsonContentType(); diff --git a/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java index 8432acaea..b72fe9cdc 100644 --- a/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java +++ b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java @@ -16,6 +16,7 @@ package feign.codec; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import feign.Capability; import feign.Feign; @@ -29,7 +30,7 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; -/** How {@link MultiEncoder} behaves when a {@link Capability} wraps the configured encoder. */ +/** How {@link MultiEncoder} behaves once configured on a {@link Feign} builder. */ class MultiEncoderCapabilityTest { interface MixedApi { @@ -92,6 +93,42 @@ private static RequestTemplate template(String contentType) { return template; } + @Test + void encodersOnTheBuilderRouteInTheOrderGiven() { + AtomicReference captured = new AtomicReference<>(); + + MixedApi api = + target( + Feign.builder() + .encoders( + PredicatedEncoder.of( + EncoderPredicate.jsonContentType(), new TaggingEncoder("json")), + PredicatedEncoder.of(EncoderPredicate.any(), new TaggingEncoder("fallback"))), + captured); + + api.json("{}"); + assertThat(captured.get()).isEqualTo("json"); + + api.xml(""); + assertThat(captured.get()).isEqualTo("fallback"); + } + + @Test + void encodersOnTheBuilderFailWhenNothingAccepts() { + MixedApi api = + target( + Feign.builder() + .encoders( + PredicatedEncoder.of( + EncoderPredicate.jsonContentType(), new TaggingEncoder("json"))), + new AtomicReference<>()); + + assertThatThrownBy(() -> api.xml("")) + .isInstanceOf(EncodeException.class) + .hasMessageContaining("Unable to encode java.lang.String (Content-Type: application/xml)") + .hasMessageContaining("TaggingEncoder when Content-Type is JSON"); + } + @Test void capabilityWrapsTheCompositeAndRoutingStillWorks() { CountingCapability capability = new CountingCapability(); @@ -101,9 +138,10 @@ void capabilityWrapsTheCompositeAndRoutingStillWorks() { target( Feign.builder() .encoder( - MultiEncoder.builder(new TaggingEncoder("fallback")) + MultiEncoder.builder() .add(EncoderPredicate.jsonContentType(), new TaggingEncoder("json")) .add(EncoderPredicate.xmlContentType(), new TaggingEncoder("xml")) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) .build()) .addCapability(capability), captured); @@ -120,8 +158,8 @@ void capabilityWrapsTheCompositeAndRoutingStillWorks() { } /** - * A wrapper that does not forward {@code canEncode} claims every request, which is why the - * metrics modules' {@code MeteredEncoder} forwards it to its delegate. + * A wrapper that answers {@code canEncode} for itself instead of forwarding claims every request, + * which is why the metrics modules' {@code MeteredEncoder} forwards it to its delegate. */ @Test void wrappingWithoutForwardingCanEncodeErasesSelfDeclaration() { @@ -138,7 +176,18 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { } }; - PredicatedEncoder naive = jsonOnly::encode; + PredicatedEncoder naive = + new PredicatedEncoder() { + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return true; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + jsonOnly.encode(object, bodyType, template); + } + }; PredicatedEncoder forwarding = new PredicatedEncoder() { @@ -154,15 +203,17 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { }; RequestTemplate naiveTemplate = template("application/xml"); - MultiEncoder.builder(new TaggingEncoder("fallback")) + MultiEncoder.builder() .add(naive) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) .build() .encode("body", String.class, naiveTemplate); assertThat(naiveTemplate.requestBody().asString()).isEqualTo("json"); RequestTemplate forwardedTemplate = template("application/xml"); - MultiEncoder.builder(new TaggingEncoder("fallback")) + MultiEncoder.builder() .add(forwarding) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) .build() .encode("body", String.class, forwardedTemplate); assertThat(forwardedTemplate.requestBody().asString()).isEqualTo("fallback"); diff --git a/core/src/test/java/feign/codec/MultiEncoderTest.java b/core/src/test/java/feign/codec/MultiEncoderTest.java index c00021ad4..88914e77e 100644 --- a/core/src/test/java/feign/codec/MultiEncoderTest.java +++ b/core/src/test/java/feign/codec/MultiEncoderTest.java @@ -69,7 +69,8 @@ void routesToTheEncoderThatDeclaresItCanHandleTheRequest() { SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); RequestTemplate template = templateWithContentType("application/json"); encoder.encode("body", String.class, template); @@ -85,7 +86,10 @@ void pairsAPredicateWithAnEncoderThatDoesNotDeclareItself() { RecordingEncoder fallback = new RecordingEncoder("fallback"); Encoder encoder = - MultiEncoder.builder(fallback).add(EncoderPredicate.xmlContentType(), xml).build(); + MultiEncoder.builder() + .add(EncoderPredicate.xmlContentType(), xml) + .add(EncoderPredicate.any(), fallback) + .build(); encoder.encode("body", String.class, templateWithContentType("application/xml")); @@ -101,10 +105,11 @@ void mixesSelfDeclaringEncodersAndPairs() { RecordingEncoder fallback = new RecordingEncoder("fallback"); Encoder encoder = - MultiEncoder.builder(fallback) + MultiEncoder.builder() .add(json) .add(EncoderPredicate.xmlContentType(), xml) .add(EncoderPredicate.bodyType(byte[].class), binary) + .add(EncoderPredicate.any(), fallback) .build(); encoder.encode( @@ -119,9 +124,8 @@ void mixesSelfDeclaringEncodersAndPairs() { @Test void matchesSuffixedContentTypes() { SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); - RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); + Encoder encoder = MultiEncoder.builder().add(json).build(); encoder.encode("body", String.class, templateWithContentType("application/vnd.github+json")); @@ -129,11 +133,12 @@ void matchesSuffixedContentTypes() { } @Test - void fallsBackWhenNoDelegateAccepts() { + void fallsBackToTheEncoderThatAcceptsAnything() { SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); RequestTemplate template = templateWithContentType("text/plain"); encoder.encode("body", String.class, template); @@ -148,7 +153,8 @@ void fallsBackWhenNoContentTypeIsSet() { SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); RecordingEncoder fallback = new RecordingEncoder("fallback"); - Encoder encoder = MultiEncoder.builder(fallback).add(json).build(); + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); encoder.encode("body", String.class, templateWithContentType(null)); @@ -156,24 +162,12 @@ void fallsBackWhenNoContentTypeIsSet() { } @Test - void withNoDelegatesEverythingGoesToTheDefaultEncoder() { - RecordingEncoder fallback = new RecordingEncoder("fallback"); - - Encoder encoder = MultiEncoder.builder(fallback).build(); - - encoder.encode("body", String.class, templateWithContentType("application/json")); - - assertThat(fallback.invoked).isTrue(); - } - - @Test - void delegatesAreConsultedInOrder() { + void encodersAreConsultedInOrder() { RecordingEncoder first = new RecordingEncoder("first"); RecordingEncoder second = new RecordingEncoder("second"); - RecordingEncoder fallback = new RecordingEncoder("fallback"); Encoder encoder = - MultiEncoder.builder(fallback) + MultiEncoder.builder() .add(EncoderPredicate.jsonContentType(), first) .add(EncoderPredicate.jsonContentType(), second) .build(); @@ -185,18 +179,47 @@ void delegatesAreConsultedInOrder() { } @Test - void anEncoderWithoutAPredicateAcceptsEverything() { - // a bare lambda is a PredicatedEncoder whose default canEncode returns true - RecordingEncoder fallback = new RecordingEncoder("fallback"); - PredicatedEncoder greedy = (object, bodyType, template) -> template.body("greedy"); + void pairingReplacesWhatTheEncoderDeclaresAboutItself() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); - Encoder encoder = MultiEncoder.builder(fallback).add(greedy).build(); + Encoder encoder = + MultiEncoder.builder().add(PredicatedEncoder.of(EncoderPredicate.any(), json)).build(); - RequestTemplate template = templateWithContentType("text/plain"); - encoder.encode("body", String.class, template); + encoder.encode("body", String.class, templateWithContentType("text/plain")); - assertThat(fallback.invoked).isFalse(); - assertThat(template.requestBody().asString()).isEqualTo("greedy"); + assertThat(json.invoked).isTrue(); + } + + @Test + void narrowingKeepsWhatTheEncoderDeclaresAboutItself() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + PredicatedEncoder narrowed = + PredicatedEncoder.narrowing( + EncoderPredicate.contentType("application/vnd.acme+json"), json); + + assertThat( + narrowed.canEncode("body", String.class, templateWithContentType("application/json"))) + .isFalse(); + assertThat( + narrowed.canEncode( + "body", String.class, templateWithContentType("application/vnd.acme+json"))) + .isTrue(); + assertThat(narrowed) + .hasToString( + "SelfDeclaringJsonEncoder when (Content-Type is application/vnd.acme+json" + + " and SelfDeclaringJsonEncoder accepts it)"); + } + + @Test + void narrowingAnEncoderThatDeclaresNothingIsJustThePredicate() { + RecordingEncoder plain = new RecordingEncoder("plain"); + PredicatedEncoder narrowed = + PredicatedEncoder.narrowing(EncoderPredicate.jsonContentType(), plain); + + assertThat(narrowed).hasToString("RecordingEncoder when Content-Type is JSON"); + assertThat( + narrowed.canEncode("body", String.class, templateWithContentType("application/json"))) + .isTrue(); } @Test @@ -207,9 +230,7 @@ void propagatesEncodeExceptionFromDelegate() { }; Encoder encoder = - MultiEncoder.builder(new DefaultEncoder()) - .add(EncoderPredicate.jsonContentType(), failing) - .build(); + MultiEncoder.builder().add(EncoderPredicate.jsonContentType(), failing).build(); assertThatThrownBy( () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) @@ -217,27 +238,83 @@ void propagatesEncodeExceptionFromDelegate() { .hasMessage("boom"); } + @Test + void throwsWhenNoEncoderAcceptsTheRequest() { + Encoder encoder = + MultiEncoder.builder() + .add(new SelfDeclaringJsonEncoder()) + .add(EncoderPredicate.xmlContentType(), new RecordingEncoder("xml")) + .build(); + + assertThatThrownBy( + () -> encoder.encode("body", String.class, templateWithContentType("text/plain"))) + .isInstanceOf(EncodeException.class) + .hasMessage( + "Unable to encode java.lang.String (Content-Type: text/plain)." + + " Encoders tried, in order:" + + "\n - SelfDeclaringJsonEncoder" + + "\n - RecordingEncoder when Content-Type is XML" + + "\nAdd an encoder guarded by EncoderPredicate.any() last to act as a default."); + } + + @Test + void theFailureNamesTheRequestWhenTheTemplateHasOne() { + RequestTemplate template = templateWithContentType("text/plain"); + template.method(Request.HttpMethod.POST); + template.uri("/orders"); + + Encoder encoder = MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build(); + + assertThatThrownBy(() -> encoder.encode("body", String.class, template)) + .isInstanceOf(EncodeException.class) + .hasMessageContaining( + "Unable to encode java.lang.String (Content-Type: text/plain) for POST /orders."); + } + + @Test + void theFailureReportsAMissingContentType() { + Encoder encoder = MultiEncoder.builder().add(new SelfDeclaringJsonEncoder()).build(); + + assertThatThrownBy(() -> encoder.encode("body", String.class, templateWithContentType(null))) + .isInstanceOf(EncodeException.class) + .hasMessageContaining("(Content-Type: not set)"); + } + + @Test + void throwsWhenNoEncodersAreConfigured() { + Encoder encoder = MultiEncoder.builder().build(); + + assertThatThrownBy( + () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) + .isInstanceOf(EncodeException.class) + .hasMessage( + "Unable to encode java.lang.String (Content-Type: application/json)." + + " No encoders were configured."); + } + @Test void rejectsNullArguments() { - assertThatThrownBy(() -> MultiEncoder.builder(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("defaultEncoder cannot be null"); - assertThatThrownBy(() -> MultiEncoder.builder(new DefaultEncoder()).add(null)) + assertThatThrownBy(() -> MultiEncoder.builder().add(null)) .isInstanceOf(NullPointerException.class) .hasMessage("encoder cannot be null"); - assertThatThrownBy( - () -> MultiEncoder.builder(new DefaultEncoder()).add(null, new DefaultEncoder())) + assertThatThrownBy(() -> MultiEncoder.builder().add(null, new DefaultEncoder())) .isInstanceOf(NullPointerException.class) .hasMessage("predicate cannot be null"); + assertThatThrownBy(() -> MultiEncoder.builder().add(EncoderPredicate.any(), null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("encoder cannot be null"); } @Test - void toStringDescribesDelegates() { + void toStringDescribesEncoders() { Encoder encoder = - MultiEncoder.builder(new DefaultEncoder()) + MultiEncoder.builder() + .add(new SelfDeclaringJsonEncoder()) .add(EncoderPredicate.jsonContentType(), new RecordingEncoder("json")) .build(); - assertThat(encoder.toString()).startsWith("MultiEncoder{defaultEncoder="); + assertThat(encoder.toString()) + .isEqualTo( + "MultiEncoder[SelfDeclaringJsonEncoder, RecordingEncoder when Content-Type is JSON]"); } } diff --git a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java index 67c9bd217..a26600838 100644 --- a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java +++ b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java @@ -22,6 +22,7 @@ import feign.codec.DefaultEncoder; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import feign.form.FormEncoder; import feign.form.MultipartFormContentProcessor; import java.lang.reflect.Type; @@ -42,10 +43,22 @@ public SpringFormEncoder() { this(new DefaultEncoder()); } + /** + * Creates a Spring form encoder that declares what it can handle, for use with {@code + * MultiEncoder}. It has no delegate, so a request it does not accept is left for the other + * encoders registered alongside it. + * + * @return a Spring form encoder guarded by {@link FormEncoder#formRequests()} + */ + public static PredicatedEncoder createPredicatedFormEncoder() { + return PredicatedEncoder.of(FormEncoder.formRequests(), new SpringFormEncoder(null)); + } + /** * Constructor with specified delegate encoder. * - * @param delegate delegate encoder, if this encoder couldn't encode object. + * @param delegate delegate encoder, if this encoder couldn't encode object. {@code null} leaves + * this encoder without one, see {@link FormEncoder#FormEncoder(Encoder)}. */ public SpringFormEncoder(Encoder delegate) { super(delegate); diff --git a/form/src/main/java/feign/form/FormEncoder.java b/form/src/main/java/feign/form/FormEncoder.java index fb05cde7c..3d2ad676a 100644 --- a/form/src/main/java/feign/form/FormEncoder.java +++ b/form/src/main/java/feign/form/FormEncoder.java @@ -25,6 +25,8 @@ import feign.codec.DefaultEncoder; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.EncoderPredicate; +import feign.codec.PredicatedEncoder; import java.lang.reflect.Type; import java.nio.charset.Charset; import java.nio.charset.IllegalCharsetNameException; @@ -48,6 +50,16 @@ public class FormEncoder implements Encoder { private static final Pattern CHARSET_PATTERN; + /** Stands in for a delegate that was never supplied, see {@link #FormEncoder(Encoder)}. */ + private static final Encoder NO_DELEGATE = + (object, bodyType, template) -> { + throw new EncodeException( + "This form encoder has no delegate encoder, so it can only encode form and multipart" + + " requests, and " + + bodyType + + " is neither. Register an encoder that handles it."); + }; + static { CONTENT_TYPE_HEADER = "Content-Type"; CHARSET_PATTERN = Pattern.compile("(?<=charset=)([\\w\\-]+)"); @@ -65,13 +77,16 @@ public FormEncoder() { /** * Constructor with specified delegate encoder. * - * @param delegate delegate encoder, if this encoder couldn't encode object. + * @param delegate delegate encoder, if this encoder couldn't encode object. {@code null} leaves + * this encoder without one, in which case anything it cannot encode itself fails with an + * {@link EncodeException} rather than being passed on. */ public FormEncoder(Encoder delegate) { - this.delegate = delegate; + this.delegate = delegate == null ? NO_DELEGATE : delegate; val list = - asList(new MultipartFormContentProcessor(delegate), new UrlencodedFormContentProcessor()); + asList( + new MultipartFormContentProcessor(this.delegate), new UrlencodedFormContentProcessor()); processors = new HashMap(list.size(), 1.F); for (ContentProcessor processor : list) { @@ -79,6 +94,37 @@ public FormEncoder(Encoder delegate) { } } + /** + * Creates a form encoder that declares what it can handle, for use with {@code MultiEncoder}. + * + *

It has no delegate: a request it does not accept is left for the other encoders registered + * alongside it, instead of being swallowed by a fallback of its own. + * + *

+   * Feign.builder()
+   *     .encoders(FormEncoder.createPredicatedFormEncoder(), new JacksonEncoder());
+   * 
+ * + * @return a form encoder guarded by {@link #formRequests()} + */ + public static PredicatedEncoder createPredicatedFormEncoder() { + return PredicatedEncoder.of(formRequests(), new FormEncoder(null)); + } + + /** + * The requests a delegate-less form encoder can handle: a form or multipart {@code Content-Type}, + * carrying a body this encoder knows how to turn into fields. + * + * @return the predicate + */ + public static EncoderPredicate formRequests() { + return EncoderPredicate.describedAs( + "Content-Type is a form type and the body is a map or a user pojo", + (object, bodyType, template) -> + ContentType.of(getContentTypeValue(template.headers())) != ContentType.UNDEFINED + && (object instanceof Map || (bodyType != null && isUserPojo(bodyType)))); + } + @Override @SuppressWarnings("unchecked") public void encode(Object object, Type bodyType, RequestTemplate template) @@ -115,7 +161,7 @@ public final ContentProcessor getContentProcessor(ContentType type) { } @SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop") - private String getContentTypeValue(Map> headers) { + private static String getContentTypeValue(Map> headers) { for (val entry : headers.entrySet()) { if (!entry.getKey().equalsIgnoreCase(CONTENT_TYPE_HEADER)) { continue; diff --git a/form/src/test/java/feign/form/PredicatedFormEncoderTest.java b/form/src/test/java/feign/form/PredicatedFormEncoderTest.java new file mode 100644 index 000000000..449202a8d --- /dev/null +++ b/form/src/test/java/feign/form/PredicatedFormEncoderTest.java @@ -0,0 +1,104 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.RequestTemplate; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.EncoderPredicate; +import feign.codec.MultiEncoder; +import feign.codec.PredicatedEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class PredicatedFormEncoderTest { + + private static RequestTemplate template(String contentType) { + RequestTemplate template = new RequestTemplate(); + if (contentType != null) { + template.header("Content-Type", contentType); + } + return template; + } + + private static Map data() { + Map data = new LinkedHashMap<>(); + data.put("foo", "bar"); + return data; + } + + @Test + void acceptsFormRequests() { + PredicatedEncoder encoder = FormEncoder.createPredicatedFormEncoder(); + + assertThat( + encoder.canEncode( + data(), Map.class, template("application/x-www-form-urlencoded; charset=utf-8"))) + .isTrue(); + assertThat(encoder.canEncode(data(), Map.class, template("multipart/form-data"))).isTrue(); + } + + @Test + void leavesEverythingElseToTheOtherEncoders() { + PredicatedEncoder encoder = FormEncoder.createPredicatedFormEncoder(); + + assertThat(encoder.canEncode("body", String.class, template("application/json"))).isFalse(); + assertThat(encoder.canEncode(data(), Map.class, template(null))).isFalse(); + assertThat(encoder.canEncode("body", String.class, template("multipart/form-data"))).isFalse(); + } + + @Test + void encodesTheFormItAccepted() { + RequestTemplate template = template("application/x-www-form-urlencoded"); + + FormEncoder.createPredicatedFormEncoder().encode(data(), Map.class, template); + + assertThat(new String(template.body(), StandardCharsets.UTF_8)).isEqualTo("foo=bar"); + } + + @Test + void routesAlongsideOtherEncoders() { + Encoder json = (object, bodyType, template) -> template.body("json"); + + Encoder encoder = + MultiEncoder.builder() + .add(FormEncoder.createPredicatedFormEncoder()) + .add(EncoderPredicate.jsonContentType(), json) + .build(); + + RequestTemplate form = template("application/x-www-form-urlencoded"); + encoder.encode(data(), Map.class, form); + assertThat(new String(form.body(), StandardCharsets.UTF_8)).isEqualTo("foo=bar"); + + RequestTemplate other = template("application/json"); + encoder.encode("body", String.class, other); + assertThat(other.requestBody().asString()).isEqualTo("json"); + } + + @Test + void withoutADelegateAnythingItCannotEncodeFails() { + RequestTemplate template = template("application/x-www-form-urlencoded"); + + assertThatThrownBy(() -> new FormEncoder(null).encode("body", String.class, template)) + .isInstanceOf(EncodeException.class) + .hasMessageContaining("This form encoder has no delegate encoder"); + } +} From cfdd1935518226c53e81d0355649069e4cfba9ed Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Thu, 20 Aug 2026 12:12:44 -0300 Subject: [PATCH 44/45] Drop the multi-decoder default decoder in favour of an explicit any() predicate Signed-off-by: Marvin Froeder --- CHANGELOG.md | 5 +- README.md | 74 ++++++--- core/src/main/java/feign/BaseBuilder.java | 20 ++- .../java/feign/codec/DecoderPredicate.java | 71 +++++++-- .../main/java/feign/codec/MultiDecoder.java | 119 ++++++++------- .../main/java/feign/codec/PairedDecoder.java | 79 ++++++++++ .../java/feign/codec/PredicatedDecoder.java | 63 +++++++- .../codec/MultiDecoderCapabilityTest.java | 49 ++++-- .../java/feign/codec/MultiDecoderTest.java | 144 ++++++++++++------ 9 files changed, 467 insertions(+), 157 deletions(-) create mode 100644 core/src/main/java/feign/codec/PairedDecoder.java diff --git a/CHANGELOG.md b/CHANGELOG.md index eac92e641..49b183fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ * Add `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, letting a single client route each response to the right decoder. Decoders declare what they can handle by implementing `PredicatedDecoder`; anything else is paired with a predicate via - `MultiDecoder.builder(defaultDecoder)`. The first-party JSON decoders (Gson, Jackson, Jackson 3, + `PredicatedDecoder.of(predicate, decoder)` or `MultiDecoder.builder()`. Decoders are consulted in + the order given and a response nothing accepts fails with a `DecodeException` naming what was + tried, so a default is a decoder guarded by `DecoderPredicate.any()` listed last. The first-party + JSON decoders (Gson, Jackson, Jackson 3, Jackson Jr, Jackson JAXB, Moshi, Fastjson2, JSON-java) and XML decoders (JAXB, JAXB Jakarta, SAX, SOAP, SOAP Jakarta) now declare themselves, and `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward `canDecode` to the decoder they wrap. The `Decoder` interface is diff --git a/README.md b/README.md index 12f1b7601..c7d0e0390 100644 --- a/README.md +++ b/README.md @@ -668,10 +668,11 @@ public class Example { > This API is `@Experimental` and may change incompatibly, or be removed, in a future release. A single client sometimes has to read more than one format — JSON for most endpoints, XML for -a legacy one, plain text for a health check. `MultiDecoder` routes each response to the right -decoder, falling back to a default when none applies. +a legacy one, plain text for a health check. `MultiDecoder` hands each response to the first decoder +that accepts it. -Most first-party decoders already declare what they can handle, so they can simply be added: +Most first-party decoders already declare what they can handle, so they can simply be listed, in the +order they should be consulted: ```java interface MixedClient { @@ -685,36 +686,57 @@ interface MixedClient { public class Example { public static void main(String[] args) { MixedClient client = Feign.builder() - .decoder(new DefaultDecoder(), new GsonDecoder(), new JAXBDecoder()) + .decoders(new GsonDecoder(), new JAXBDecoder()) .target(MixedClient.class, "https://foo.com"); } } ``` -The first argument is the default decoder, used when nothing else accepts the response. Routing is -driven by what the server actually sent back, so a client that talks to endpoints answering -`application/json` and `application/xml` no longer needs one Feign instance per format. +Routing is driven by what the server actually sent back, so a client that talks to endpoints +answering `application/json` and `application/xml` no longer needs one Feign instance per format. -For a decoder that does not declare itself — including one you do not control — pair it -with a `DecoderPredicate` using the builder: +There is no implicit fallback. A response that no decoder accepts fails with a `DecodeException` +naming the decoders that were tried and what each one wants: + +``` +Unable to decode 200 response (Content-Type: text/plain) as com.example.Order. Decoders tried, in order: + - GsonDecoder + - JAXBDecoder +Add a decoder guarded by DecoderPredicate.any() last to act as a default. +``` + +To get a default, pair a decoder with the predicate that accepts everything and list it **last**: + +```java +Feign.builder() + .decoders( + new GsonDecoder(), + new JAXBDecoder(), + PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder())); +``` + +The same pairing works for any decoder that does not declare itself, including one you do not +control. `MultiDecoder.builder()` spells it out when a lambda reads better than a wrapper: ```java Decoder decoder = - MultiDecoder.builder(new DefaultDecoder()) - .add(new GsonDecoder()) // declares itself - .add(DecoderPredicate.xmlContentType(), someXmlDecoder) // paired + MultiDecoder.builder() + .add(new GsonDecoder()) // declares itself + .add(DecoderPredicate.xmlContentType(), someXmlDecoder) // paired .add((response, type) -> type == byte[].class, binaryDecoder) + .add(DecoderPredicate.any(), new DefaultDecoder()) // the default, last .build(); ``` -Delegates are consulted in the order they were added, so put the narrowest predicate first. +Decoders are consulted in the order they were added, so put the narrowest one first. ##### Declaring your own decoder -Implement `PredicatedDecoder` alongside `Decoder` and override `canDecode`: +Implement `PredicatedDecoder` and say what you handle. `canDecode` has no default: a decoder that +declares nothing would claim every response, which is rarely what its author meant. ```java -public class MyDecoder implements Decoder, PredicatedDecoder { +public class MyDecoder implements PredicatedDecoder { @Override public boolean canDecode(Response response, Type type) { @@ -728,16 +750,28 @@ public class MyDecoder implements Decoder, PredicatedDecoder { } ``` -`DecoderPredicate` ships with `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, -`emptyBody()`, `status(codes...)` and `returnType(type)`, plus `and`/`or`/`negate` to combine them. +`DecoderPredicate` is the `@FunctionalInterface` here, so predicates can be lambdas. It ships with +`any()`, `jsonContentType()`, `xmlContentType()`, `contentType(mediaType)`, `emptyBody()`, +`status(codes...)` and `returnType(type)`, plus `and`/`or`/`negate` to combine them. Each one +describes itself, which is what shows up in the error message above; wrap your own lambdas in +`DecoderPredicate.describedAs("it is Tuesday", ...)` to read as well. + +`PredicatedDecoder.of(predicate, decoder)` replaces whatever the decoder says about itself, so it +can widen a decoder as well as narrow it. To keep the decoder's own declaration and add to it, use +`narrowing`: + +```java +// JSON responses as usual, but only when the call actually succeeded +PredicatedDecoder.narrowing(DecoderPredicate.status(200, 201), new GsonDecoder()); +``` **Predicates must not read the response body.** For most clients it is a single-pass stream, so consuming it in `canDecode` would leave nothing for the decoder that is eventually chosen. Decide on the status, the headers and the expected type instead. -**If you wrap a decoder, forward `canDecode` to your delegate.** A wrapper that does not will claim -every response, because the default `canDecode` accepts everything. `OptionalDecoder` and the -metrics modules' `MeteredDecoder` forward for exactly this reason. +**If you wrap a decoder, forward `canDecode` to your delegate**, otherwise wrapping silently changes +what the decoder handles. `OptionalDecoder` and the metrics modules' `MeteredDecoder` forward for +exactly this reason. ### Encoders The simplest way to send a request body to a server is to define a `POST` method that has a `String` or `byte[]` parameter without any annotations on it. You will likely need to add a `Content-Type` header. diff --git a/core/src/main/java/feign/BaseBuilder.java b/core/src/main/java/feign/BaseBuilder.java index 59526ace0..4e625bff6 100644 --- a/core/src/main/java/feign/BaseBuilder.java +++ b/core/src/main/java/feign/BaseBuilder.java @@ -22,6 +22,7 @@ import feign.Request.Options; import feign.codec.Codec; import feign.codec.Decoder; +import feign.codec.DecoderPredicate; import feign.codec.DefaultDecoder; import feign.codec.DefaultEncoder; import feign.codec.DefaultErrorDecoder; @@ -104,23 +105,28 @@ public B decoder(Decoder decoder) { /** * Configures a {@link MultiDecoder} built from decoders that declare their own applicability. * - *

Each {@link PredicatedDecoder} is consulted in the order given; {@code defaultDecoder} is - * the fallback used when none accepts the response. + *

Decoders are consulted in the order given, and the first one that accepts the response + * decodes it. There is no implicit fallback: pair a decoder with {@link DecoderPredicate#any()} + * and list it last to act as a default, otherwise a response nothing accepts fails with a {@link + * feign.codec.DecodeException}. * *

    * Feign.builder()
-   *     .decoder(new DefaultDecoder(), new JacksonDecoder(), new JAXBDecoder())
+   *     .decoders(
+   *         new JacksonDecoder(),
+   *         new JAXBDecoder(),
+   *         PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()))
    * 
* *

To pair a predicate with a decoder that does not implement {@link PredicatedDecoder}, use - * {@link MultiDecoder#builder(Decoder)} instead. + * {@link PredicatedDecoder#of(DecoderPredicate, Decoder)} as above, or {@link + * MultiDecoder#builder()} for the same thing spelled out. * - * @param defaultDecoder the decoder used when no delegate accepts the response * @param decoders the predicated decoders, consulted in the order given */ @Experimental - public B decoder(Decoder defaultDecoder, PredicatedDecoder... decoders) { - MultiDecoder.Builder builder = MultiDecoder.builder(defaultDecoder); + public B decoders(PredicatedDecoder... decoders) { + MultiDecoder.Builder builder = MultiDecoder.builder(); for (PredicatedDecoder decoder : decoders) { builder.add(decoder); } diff --git a/core/src/main/java/feign/codec/DecoderPredicate.java b/core/src/main/java/feign/codec/DecoderPredicate.java index 0727c0ebe..941d9cbab 100644 --- a/core/src/main/java/feign/codec/DecoderPredicate.java +++ b/core/src/main/java/feign/codec/DecoderPredicate.java @@ -33,6 +33,10 @@ * for most clients, so consuming it here would leave nothing for the decoder that is eventually * chosen. * + *

Every predicate built here describes itself, so a {@link MultiDecoder} that cannot route a + * response can say what it did consider. Wrap your own lambdas in {@link #describedAs(String, + * DecoderPredicate)} to get the same in error messages. + * * @see PredicatedDecoder * @see MultiDecoder */ @@ -50,14 +54,48 @@ public interface DecoderPredicate { */ boolean canDecode(Response response, Type type); + /** + * Wraps a predicate so that it describes itself, which is what a {@link MultiDecoder} reports + * when no decoder accepts a response. + * + * @param description how the predicate reads in an error message, for example {@code + * "Content-Type is JSON"} + * @param predicate the predicate to describe + */ + static DecoderPredicate describedAs(String description, DecoderPredicate predicate) { + Objects.requireNonNull(description, "description cannot be null"); + Objects.requireNonNull(predicate, "predicate cannot be null"); + return new DecoderPredicate() { + + @Override + public boolean canDecode(Response response, Type type) { + return predicate.canDecode(response, type); + } + + @Override + public String toString() { + return description; + } + }; + } + + /** + * Matches every response. Pair this with a decoder registered last to make it the default of a + * {@link MultiDecoder}. + */ + static DecoderPredicate any() { + return describedAs("any response", (response, type) -> true); + } + /** Matches responses whose {@code Content-Type} header denotes JSON. */ static DecoderPredicate jsonContentType() { - return (response, type) -> Util.isJsonContentType(response); + return describedAs( + "Content-Type is JSON", (response, type) -> Util.isJsonContentType(response)); } /** Matches responses whose {@code Content-Type} header denotes XML. */ static DecoderPredicate xmlContentType() { - return (response, type) -> Util.isXmlContentType(response); + return describedAs("Content-Type is XML", (response, type) -> Util.isXmlContentType(response)); } /** @@ -66,40 +104,51 @@ static DecoderPredicate xmlContentType() { */ static DecoderPredicate contentType(String mediaType) { Objects.requireNonNull(mediaType, "mediaType cannot be null"); - return (response, type) -> Util.hasContentType(response, mediaType); + return describedAs( + "Content-Type is " + mediaType, + (response, type) -> Util.hasContentType(response, mediaType)); } /** Matches responses carrying no body, such as a {@code 204 No Content}. */ static DecoderPredicate emptyBody() { - return (response, type) -> - response.body() == null - || (response.body().length() != null && response.body().length() == 0); + return describedAs( + "body is empty", + (response, type) -> + response.body() == null + || (response.body().length() != null && response.body().length() == 0)); } /** Matches responses whose status is one of the given codes. */ static DecoderPredicate status(int... statuses) { int[] accepted = Arrays.copyOf(statuses, statuses.length); Arrays.sort(accepted); - return (response, type) -> Arrays.binarySearch(accepted, response.status()) >= 0; + return describedAs( + "status is one of " + Arrays.toString(accepted), + (response, type) -> Arrays.binarySearch(accepted, response.status()) >= 0); } /** Matches responses the caller expects to come back as exactly the given type. */ static DecoderPredicate returnType(Type expected) { Objects.requireNonNull(expected, "expected cannot be null"); - return (response, type) -> expected.equals(type); + return describedAs( + "return type is " + expected.getTypeName(), (response, type) -> expected.equals(type)); } default DecoderPredicate and(DecoderPredicate other) { Objects.requireNonNull(other, "other cannot be null"); - return (response, type) -> canDecode(response, type) && other.canDecode(response, type); + return describedAs( + "(" + this + " and " + other + ")", + (response, type) -> canDecode(response, type) && other.canDecode(response, type)); } default DecoderPredicate or(DecoderPredicate other) { Objects.requireNonNull(other, "other cannot be null"); - return (response, type) -> canDecode(response, type) || other.canDecode(response, type); + return describedAs( + "(" + this + " or " + other + ")", + (response, type) -> canDecode(response, type) || other.canDecode(response, type)); } default DecoderPredicate negate() { - return (response, type) -> !canDecode(response, type); + return describedAs("not (" + this + ")", (response, type) -> !canDecode(response, type)); } } diff --git a/core/src/main/java/feign/codec/MultiDecoder.java b/core/src/main/java/feign/codec/MultiDecoder.java index e89521023..f48ee1975 100644 --- a/core/src/main/java/feign/codec/MultiDecoder.java +++ b/core/src/main/java/feign/codec/MultiDecoder.java @@ -18,33 +18,39 @@ import feign.Experimental; import feign.FeignException; import feign.Response; +import feign.Util; import java.io.IOException; import java.lang.reflect.Type; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; /** - * A {@link Decoder} that selects a delegate per response, falling back to a default decoder when no - * delegate accepts it. + * A {@link Decoder} that hands each response to the first decoder that accepts it. * - *

Delegates come from two places. A decoder that implements {@link PredicatedDecoder} declares + *

Decoders come from two places. A decoder that implements {@link PredicatedDecoder} declares * its own applicability and can simply be added; any other decoder is paired with a {@link * DecoderPredicate} at the call site: * *

  * Feign.builder()
  *     .decoder(
- *         MultiDecoder.builder(new DefaultDecoder())
+ *         MultiDecoder.builder()
  *             .add(new JacksonDecoder())
  *             .add(DecoderPredicate.xmlContentType(), new JAXBDecoder())
  *             .add((response, type) -> type == byte[].class, new BinaryDecoder())
+ *             .add(DecoderPredicate.any(), new DefaultDecoder())
  *             .build());
  * 
* - *

Delegates are consulted in the order they were added, so the narrowest predicate should come - * first. The default decoder is consulted last. + *

Decoders are consulted in the order they were added, so the narrowest one comes first. There + * is no implicit fallback: a response no decoder accepts fails with a {@link DecodeException} + * naming what was tried. Add a decoder guarded by {@link DecoderPredicate#any()} last to act as a + * default, as above. * * @see PredicatedDecoder * @see DecoderPredicate @@ -52,77 +58,83 @@ @Experimental public class MultiDecoder implements Decoder { - private final Decoder defaultDecoder; + private final List decoders; - private final List delegates; - - private MultiDecoder(Decoder defaultDecoder, List delegates) { - this.defaultDecoder = defaultDecoder; - this.delegates = Collections.unmodifiableList(new ArrayList<>(delegates)); + private MultiDecoder(List decoders) { + this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders)); } - /** - * Starts building a multi-decoder. - * - * @param defaultDecoder the decoder used when no delegate accepts the response - * @return the builder - */ - public static Builder builder(Decoder defaultDecoder) { - return new Builder(defaultDecoder); + /** Starts building a multi-decoder. */ + public static Builder builder() { + return new Builder(); } /** - * Decodes using the first delegate that accepts the response, or the default decoder if none do. + * Decodes using the first decoder that accepts the response. * * @param response {@inheritDoc} * @param type {@inheritDoc} * @return {@inheritDoc} * @throws IOException {@inheritDoc} - * @throws DecodeException {@inheritDoc} + * @throws DecodeException when no decoder accepts the response, or the chosen one fails * @throws FeignException {@inheritDoc} */ @Override public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException { - for (Delegate delegate : delegates) { - if (delegate.predicate.canDecode(response, type)) { - return delegate.decoder.decode(response, type); + for (PredicatedDecoder decoder : decoders) { + if (decoder.canDecode(response, type)) { + return decoder.decode(response, type); } } - return defaultDecoder.decode(response, type); + throw new DecodeException( + response.status(), unableToDecode(response, type), response.request()); } - @Override - public String toString() { - return "MultiDecoder{defaultDecoder=" + defaultDecoder + ", delegates=" + delegates + '}'; + private String unableToDecode(Response response, Type type) { + StringBuilder message = + new StringBuilder("Unable to decode ") + .append(response.status()) + .append(" response (Content-Type: ") + .append(contentTypes(response)) + .append(") as ") + .append(type == null ? "the expected type" : type.getTypeName()); + if (decoders.isEmpty()) { + return message.append(". No decoders were configured.").toString(); + } + message.append(". Decoders tried, in order:"); + for (PredicatedDecoder decoder : decoders) { + message.append("\n - ").append(PairedDecoder.describe(decoder)); + } + return message + .append("\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default.") + .toString(); } - private static final class Delegate { - private final DecoderPredicate predicate; - private final Decoder decoder; - - Delegate(DecoderPredicate predicate, Decoder decoder) { - this.predicate = predicate; - this.decoder = decoder; - } + private static String contentTypes(Response response) { + String contentTypes = + response.headers().entrySet().stream() + .filter(header -> Util.CONTENT_TYPE.equalsIgnoreCase(header.getKey())) + .map(Map.Entry::getValue) + .filter(Objects::nonNull) + .flatMap(Collection::stream) + .collect(Collectors.joining(", ")); + return contentTypes.isEmpty() ? "not set" : contentTypes; + } - @Override - public String toString() { - return decoder.toString(); - } + @Override + public String toString() { + return "MultiDecoder" + + decoders.stream().map(PairedDecoder::describe).collect(Collectors.toList()); } - /** Collects the delegates of a {@link MultiDecoder}. */ + /** Collects the decoders of a {@link MultiDecoder}. */ @Experimental public static final class Builder { - private final Decoder defaultDecoder; + private final List decoders = new ArrayList<>(); - private final List delegates = new ArrayList<>(); - - private Builder(Decoder defaultDecoder) { - this.defaultDecoder = Objects.requireNonNull(defaultDecoder, "defaultDecoder cannot be null"); - } + private Builder() {} /** * Adds a decoder that declares its own applicability. @@ -130,8 +142,8 @@ private Builder(Decoder defaultDecoder) { * @param decoder the decoder, consulted via {@link PredicatedDecoder#canDecode} */ public Builder add(PredicatedDecoder decoder) { - Objects.requireNonNull(decoder, "decoder cannot be null"); - return add(decoder::canDecode, decoder); + decoders.add(Objects.requireNonNull(decoder, "decoder cannot be null")); + return this; } /** @@ -142,15 +154,12 @@ public Builder add(PredicatedDecoder decoder) { * @param decoder the decoder to delegate to */ public Builder add(DecoderPredicate predicate, Decoder decoder) { - Objects.requireNonNull(predicate, "predicate cannot be null"); - Objects.requireNonNull(decoder, "decoder cannot be null"); - delegates.add(new Delegate(predicate, decoder)); - return this; + return add(PredicatedDecoder.of(predicate, decoder)); } /** Builds the multi-decoder. */ public MultiDecoder build() { - return new MultiDecoder(defaultDecoder, delegates); + return new MultiDecoder(decoders); } } } diff --git a/core/src/main/java/feign/codec/PairedDecoder.java b/core/src/main/java/feign/codec/PairedDecoder.java new file mode 100644 index 000000000..f2922259b --- /dev/null +++ b/core/src/main/java/feign/codec/PairedDecoder.java @@ -0,0 +1,79 @@ +/* + * Copyright © 2012 The Feign Authors (feign@commonhaus.dev) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package feign.codec; + +import feign.FeignException; +import feign.Response; +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.Objects; + +/** A decoder that does not declare itself, guarded by a predicate supplied at the call site. */ +final class PairedDecoder implements PredicatedDecoder { + + private final DecoderPredicate predicate; + + private final Decoder decoder; + + PairedDecoder(DecoderPredicate predicate, Decoder decoder) { + this.predicate = Objects.requireNonNull(predicate, "predicate cannot be null"); + this.decoder = Objects.requireNonNull(decoder, "decoder cannot be null"); + } + + @Override + public boolean canDecode(Response response, Type type) { + return predicate.canDecode(response, type); + } + + @Override + public Object decode(Response response, Type type) + throws IOException, DecodeException, FeignException { + return decoder.decode(response, type); + } + + @Override + public String toString() { + return describe(decoder) + " when " + predicate; + } + + /** Requires both the predicate and, when the decoder declares one, its own applicability. */ + static DecoderPredicate narrow(DecoderPredicate predicate, Decoder decoder) { + Objects.requireNonNull(predicate, "predicate cannot be null"); + Objects.requireNonNull(decoder, "decoder cannot be null"); + if (!(decoder instanceof PredicatedDecoder)) { + return predicate; + } + if (decoder instanceof PairedDecoder) { + return predicate.and(((PairedDecoder) decoder).predicate); + } + PredicatedDecoder predicated = (PredicatedDecoder) decoder; + return predicate.and( + DecoderPredicate.describedAs(describe(decoder) + " accepts it", predicated::canDecode)); + } + + /** The decoder's own {@code toString} when it has one, its class name otherwise. */ + static String describe(Decoder decoder) { + Class type = decoder.getClass(); + try { + if (type.getMethod("toString").getDeclaringClass() != Object.class) { + return decoder.toString(); + } + } catch (NoSuchMethodException ignored) { + // cannot happen, every class has toString + } + return type.getSimpleName().isEmpty() ? type.getName() : type.getSimpleName(); + } +} diff --git a/core/src/main/java/feign/codec/PredicatedDecoder.java b/core/src/main/java/feign/codec/PredicatedDecoder.java index 9d6bf5067..d8d48467a 100644 --- a/core/src/main/java/feign/codec/PredicatedDecoder.java +++ b/core/src/main/java/feign/codec/PredicatedDecoder.java @@ -26,17 +26,25 @@ * route each response to the right one without the call site having to wrap anything: * *

- * public class JacksonDecoder implements Decoder, PredicatedDecoder {
+ * public class JacksonDecoder implements PredicatedDecoder {
  *
  *   @Override
  *   public boolean canDecode(Response response, Type type) {
  *     return Util.isJsonContentType(response);
  *   }
+ *
+ *   @Override
+ *   public Object decode(Response response, Type type) throws IOException {
+ *     // ...
+ *   }
  * }
  * 
* - *

{@link Decoder#decode(Response, Type) decode} remains the only abstract method, so this stays - * a functional interface and a bare lambda is a decoder that accepts everything. + *

{@code canDecode} is deliberately abstract: a decoder that says nothing about what it handles + * would claim every response, which is almost never what its author meant. Use {@link + * #of(DecoderPredicate, Decoder)} to give an existing decoder a predicate instead of implementing + * this on it, and {@link DecoderPredicate} — which is a {@code @FunctionalInterface} — + * to write that predicate as a lambda. * *

Decoders that wrap another decoder should forward {@code canDecode} to their delegate, so that * wrapping does not discard the delegate's applicability. @@ -45,11 +53,52 @@ * @see DecoderPredicate */ @Experimental -@FunctionalInterface public interface PredicatedDecoder extends Decoder { /** - * Whether this decoder can handle the response. Defaults to accepting everything. + * Pairs any decoder with a predicate, for decoders that do not declare themselves, including ones + * you do not control. The predicate is the whole answer: whatever the decoder may declare about + * itself is replaced, so this can widen a decoder as well as narrow it. Use {@link + * #narrowing(DecoderPredicate, Decoder)} to keep the decoder's own declaration. + * + *

A decoder paired with {@link DecoderPredicate#any()} accepts everything, which is how a + * {@link MultiDecoder} is given a default: + * + *

+   * Feign.builder()
+   *     .decoders(
+   *         new JacksonDecoder(),
+   *         PredicatedDecoder.of(DecoderPredicate.any(), new DefaultDecoder()));
+   * 
+ * + * @param predicate decides whether the decoder handles a response + * @param decoder the decoder to delegate to + */ + static PredicatedDecoder of(DecoderPredicate predicate, Decoder decoder) { + return new PairedDecoder(predicate, decoder); + } + + /** + * Narrows a decoder that already declares itself, by requiring both the given predicate and the + * decoder's own {@code canDecode} to accept the response: + * + *
+   * PredicatedDecoder.narrowing(
+   *     DecoderPredicate.status(200), new JacksonDecoder());
+   * 
+ * + *

A decoder that does not implement {@link PredicatedDecoder} declares nothing to narrow, so + * this behaves like {@link #of(DecoderPredicate, Decoder)}. + * + * @param predicate narrows what the decoder handles + * @param decoder the decoder to delegate to + */ + static PredicatedDecoder narrowing(DecoderPredicate predicate, Decoder decoder) { + return new PairedDecoder(PairedDecoder.narrow(predicate, decoder), decoder); + } + + /** + * Whether this decoder can handle the response. * *

The response body must not be read here: it is a single-pass stream for most clients, so * consuming it would leave nothing for the decoder that is eventually chosen. @@ -59,7 +108,5 @@ public interface PredicatedDecoder extends Decoder { * caller expects back * @return {@code true} if this decoder can decode the response, {@code false} otherwise */ - default boolean canDecode(Response response, Type type) { - return true; - } + boolean canDecode(Response response, Type type); } diff --git a/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java index 024a3c94a..d286cc03a 100644 --- a/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java +++ b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java @@ -16,6 +16,7 @@ package feign.codec; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import feign.Capability; import feign.Feign; @@ -110,9 +111,10 @@ void capabilityWrapsTheCompositeAndRoutingStillWorks() { target( Feign.builder() .decoder( - MultiDecoder.builder(new TaggingDecoder("fallback")) + MultiDecoder.builder() .add(DecoderPredicate.jsonContentType(), new TaggingDecoder("json")) .add(DecoderPredicate.xmlContentType(), new TaggingDecoder("xml")) + .add(DecoderPredicate.any(), new TaggingDecoder("fallback")) .build()) .addCapability(capability), contentTypes); @@ -126,25 +128,41 @@ void capabilityWrapsTheCompositeAndRoutingStillWorks() { } @Test - void builderShorthandRoutesToSelfDeclaringDecoders() { + void decodersOnTheBuilderRouteInTheOrderGiven() { Map contentTypes = new HashMap<>(); contentTypes.put("json", "application/json"); contentTypes.put("csv", "text/csv"); MixedApi api = target( - Feign.builder().decoder(new TaggingDecoder("fallback"), new SelfDeclaringJsonDecoder()), + Feign.builder() + .decoders( + new SelfDeclaringJsonDecoder(), + PredicatedDecoder.of(DecoderPredicate.any(), new TaggingDecoder("fallback"))), contentTypes); assertThat(api.get("json")).isEqualTo("json"); assertThat(api.get("csv")).isEqualTo("fallback"); } + @Test + void decodersOnTheBuilderFailWhenNothingAccepts() { + Map contentTypes = new HashMap<>(); + contentTypes.put("csv", "text/csv"); + + MixedApi api = target(Feign.builder().decoders(new SelfDeclaringJsonDecoder()), contentTypes); + + assertThatThrownBy(() -> api.get("csv")) + .isInstanceOf(DecodeException.class) + .hasMessageContaining("Unable to decode 200 response (Content-Type: text/csv)") + .hasMessageContaining("SelfDeclaringJsonDecoder"); + } + /** The selected decoder still receives an unread body: predicates must not consume it. */ @Test void predicatesLeaveTheBodyForTheSelectedDecoder() throws IOException { Decoder decoder = - MultiDecoder.builder(new TaggingDecoder("fallback")) + MultiDecoder.builder() .add( DecoderPredicate.jsonContentType(), (response, type) -> Util.toString(response.body().asReader(Util.UTF_8))) @@ -168,14 +186,25 @@ public boolean canDecode(Response response, Type type) { } /** - * A wrapper that does not forward {@code canDecode} claims every response, which is why the - * metrics modules' {@code MeteredDecoder} forwards it to its delegate. + * A wrapper that answers {@code canDecode} for itself instead of forwarding claims every + * response, which is why the metrics modules' {@code MeteredDecoder} forwards it to its delegate. */ @Test void wrappingWithoutForwardingCanDecodeErasesSelfDeclaration() throws IOException { PredicatedDecoder jsonOnly = new SelfDeclaringJsonDecoder(); - PredicatedDecoder naive = jsonOnly::decode; + PredicatedDecoder naive = + new PredicatedDecoder() { + @Override + public boolean canDecode(Response response, Type type) { + return true; + } + + @Override + public Object decode(Response response, Type type) throws IOException { + return jsonOnly.decode(response, type); + } + }; PredicatedDecoder forwarding = new PredicatedDecoder() { @@ -191,15 +220,17 @@ public Object decode(Response response, Type type) throws IOException { }; assertThat( - MultiDecoder.builder(new TaggingDecoder("fallback")) + MultiDecoder.builder() .add(naive) + .add(DecoderPredicate.any(), new TaggingDecoder("fallback")) .build() .decode(response("application/xml", "payload"), String.class)) .isEqualTo("json"); assertThat( - MultiDecoder.builder(new TaggingDecoder("fallback")) + MultiDecoder.builder() .add(forwarding) + .add(DecoderPredicate.any(), new TaggingDecoder("fallback")) .build() .decode(response("application/xml", "payload"), String.class)) .isEqualTo("fallback"); diff --git a/core/src/test/java/feign/codec/MultiDecoderTest.java b/core/src/test/java/feign/codec/MultiDecoderTest.java index b1babf6c4..5ca7502b1 100644 --- a/core/src/test/java/feign/codec/MultiDecoderTest.java +++ b/core/src/test/java/feign/codec/MultiDecoderTest.java @@ -90,7 +90,8 @@ void routesToTheDecoderThatDeclaresItCanHandleTheResponse() throws IOException { SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); RecordingDecoder fallback = new RecordingDecoder("fallback"); - Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + Decoder decoder = + MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build(); assertThat(decoder.decode(responseWithContentType("application/json"), String.class)) .isEqualTo("json"); @@ -104,7 +105,10 @@ void pairsAPredicateWithADecoderThatDoesNotDeclareItself() throws IOException { RecordingDecoder fallback = new RecordingDecoder("fallback"); Decoder decoder = - MultiDecoder.builder(fallback).add(DecoderPredicate.xmlContentType(), xml).build(); + MultiDecoder.builder() + .add(DecoderPredicate.xmlContentType(), xml) + .add(DecoderPredicate.any(), fallback) + .build(); assertThat(decoder.decode(responseWithContentType("application/xml"), String.class)) .isEqualTo("xml"); @@ -119,10 +123,11 @@ void mixesSelfDeclaringDecodersAndPairs() throws IOException { RecordingDecoder fallback = new RecordingDecoder("fallback"); Decoder decoder = - MultiDecoder.builder(fallback) + MultiDecoder.builder() .add(json) .add(DecoderPredicate.xmlContentType(), xml) .add(DecoderPredicate.contentType("text/csv"), csv) + .add(DecoderPredicate.any(), fallback) .build(); assertThat(decoder.decode(responseWithContentType("text/csv;charset=utf-8"), String.class)) @@ -135,20 +140,20 @@ void mixesSelfDeclaringDecodersAndPairs() throws IOException { @Test void matchesSuffixedContentTypes() throws IOException { SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); - RecordingDecoder fallback = new RecordingDecoder("fallback"); - Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + Decoder decoder = MultiDecoder.builder().add(json).build(); assertThat(decoder.decode(responseWithContentType("application/vnd.github+json"), String.class)) .isEqualTo("json"); } @Test - void fallsBackToTheDefaultDecoderWhenNoDelegateAccepts() throws IOException { + void fallsBackToTheDecoderThatAcceptsAnything() throws IOException { SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); RecordingDecoder fallback = new RecordingDecoder("fallback"); - Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + Decoder decoder = + MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build(); assertThat(decoder.decode(responseWithContentType("text/plain"), String.class)) .isEqualTo("fallback"); @@ -160,19 +165,19 @@ void fallsBackWhenTheResponseCarriesNoContentType() throws IOException { SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); RecordingDecoder fallback = new RecordingDecoder("fallback"); - Decoder decoder = MultiDecoder.builder(fallback).add(json).build(); + Decoder decoder = + MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).build(); assertThat(decoder.decode(responseWithContentType(null), String.class)).isEqualTo("fallback"); } @Test - void consultsDelegatesInTheOrderTheyWereAdded() throws IOException { + void consultsDecodersInTheOrderTheyWereAdded() throws IOException { RecordingDecoder first = new RecordingDecoder("first"); RecordingDecoder second = new RecordingDecoder("second"); - RecordingDecoder fallback = new RecordingDecoder("fallback"); Decoder decoder = - MultiDecoder.builder(fallback) + MultiDecoder.builder() .add(DecoderPredicate.jsonContentType(), first) .add(DecoderPredicate.jsonContentType(), second) .build(); @@ -183,15 +188,82 @@ void consultsDelegatesInTheOrderTheyWereAdded() throws IOException { } @Test - void aBareLambdaIsADecoderThatAcceptsEverything() throws IOException { - PredicatedDecoder anything = (response, type) -> "anything"; - RecordingDecoder fallback = new RecordingDecoder("fallback"); + void pairingReplacesWhatTheDecoderDeclaresAboutItself() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); - Decoder decoder = MultiDecoder.builder(fallback).add(anything).build(); + Decoder decoder = + MultiDecoder.builder().add(PredicatedDecoder.of(DecoderPredicate.any(), json)).build(); assertThat(decoder.decode(responseWithContentType("text/plain"), String.class)) - .isEqualTo("anything"); - assertThat(fallback.invoked).isFalse(); + .isEqualTo("json"); + } + + @Test + void narrowingKeepsWhatTheDecoderDeclaresAboutItself() { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + PredicatedDecoder narrowed = PredicatedDecoder.narrowing(DecoderPredicate.status(200), json); + + assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class)) + .isTrue(); + assertThat( + narrowed.canDecode( + responseWithContentType("application/json", 204, null), String.class)) + .isFalse(); + assertThat(narrowed.canDecode(responseWithContentType("text/plain"), String.class)).isFalse(); + assertThat(narrowed) + .hasToString( + "SelfDeclaringJsonDecoder when (status is one of [200]" + + " and SelfDeclaringJsonDecoder accepts it)"); + } + + @Test + void narrowingADecoderThatDeclaresNothingIsJustThePredicate() { + RecordingDecoder plain = new RecordingDecoder("plain"); + PredicatedDecoder narrowed = + PredicatedDecoder.narrowing(DecoderPredicate.jsonContentType(), plain); + + assertThat(narrowed).hasToString("RecordingDecoder when Content-Type is JSON"); + assertThat(narrowed.canDecode(responseWithContentType("application/json"), String.class)) + .isTrue(); + } + + @Test + void throwsWhenNoDecoderAcceptsTheResponse() { + Decoder decoder = + MultiDecoder.builder() + .add(new SelfDeclaringJsonDecoder()) + .add(DecoderPredicate.xmlContentType(), new RecordingDecoder("xml")) + .build(); + + assertThatThrownBy(() -> decoder.decode(responseWithContentType("text/plain"), String.class)) + .isInstanceOf(DecodeException.class) + .hasMessage( + "Unable to decode 200 response (Content-Type: text/plain) as java.lang.String." + + " Decoders tried, in order:" + + "\n - SelfDeclaringJsonDecoder" + + "\n - RecordingDecoder when Content-Type is XML" + + "\nAdd a decoder guarded by DecoderPredicate.any() last to act as a default."); + } + + @Test + void theFailureReportsAMissingContentType() { + Decoder decoder = MultiDecoder.builder().add(new SelfDeclaringJsonDecoder()).build(); + + assertThatThrownBy(() -> decoder.decode(responseWithContentType(null), String.class)) + .isInstanceOf(DecodeException.class) + .hasMessageContaining("(Content-Type: not set)"); + } + + @Test + void throwsWhenNoDecodersAreConfigured() { + Decoder decoder = MultiDecoder.builder().build(); + + assertThatThrownBy( + () -> decoder.decode(responseWithContentType("application/json"), String.class)) + .isInstanceOf(DecodeException.class) + .hasMessage( + "Unable to decode 200 response (Content-Type: application/json) as java.lang.String." + + " No decoders were configured."); } @Test @@ -202,9 +274,7 @@ void propagatesIoExceptionsFromTheSelectedDecoder() { }; Decoder decoder = - MultiDecoder.builder(new RecordingDecoder("fallback")) - .add(DecoderPredicate.jsonContentType(), failing) - .build(); + MultiDecoder.builder().add(DecoderPredicate.jsonContentType(), failing).build(); assertThatThrownBy( () -> decoder.decode(responseWithContentType("application/json"), String.class)) @@ -213,15 +283,8 @@ void propagatesIoExceptionsFromTheSelectedDecoder() { } @Test - void rejectsANullDefaultDecoder() { - assertThatThrownBy(() -> MultiDecoder.builder(null)) - .isInstanceOf(NullPointerException.class) - .hasMessage("defaultDecoder cannot be null"); - } - - @Test - void rejectsNullDelegates() { - MultiDecoder.Builder builder = MultiDecoder.builder(new RecordingDecoder("fallback")); + void rejectsNullDecoders() { + MultiDecoder.Builder builder = MultiDecoder.builder(); assertThatThrownBy(() -> builder.add((PredicatedDecoder) null)) .isInstanceOf(NullPointerException.class) @@ -235,26 +298,15 @@ void rejectsNullDelegates() { } @Test - void describesItsDelegates() { + void describesItsDecoders() { Decoder decoder = - MultiDecoder.builder( - new RecordingDecoder("fallback") { - @Override - public String toString() { - return "fallback"; - } - }) - .add( - DecoderPredicate.jsonContentType(), - new RecordingDecoder("json") { - @Override - public String toString() { - return "json"; - } - }) + MultiDecoder.builder() + .add(new SelfDeclaringJsonDecoder()) + .add(DecoderPredicate.jsonContentType(), new RecordingDecoder("json")) .build(); assertThat(decoder.toString()) - .isEqualTo("MultiDecoder{defaultDecoder=fallback, delegates=[json]}"); + .isEqualTo( + "MultiDecoder[SelfDeclaringJsonDecoder, RecordingDecoder when Content-Type is JSON]"); } } From cd77b426aa8b8fda171dfb447078b30128decc32 Mon Sep 17 00:00:00 2001 From: Marvin Froeder Date: Thu, 20 Aug 2026 12:22:52 -0300 Subject: [PATCH 45/45] Generate the flattened feign-bom POM under target instead of the source tree Signed-off-by: Marvin Froeder --- .gitignore | 3 --- feign-bom/pom.xml | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 4e6917fa1..53adc1616 100644 --- a/.gitignore +++ b/.gitignore @@ -76,6 +76,3 @@ release.properties pom.xml.releaseBackup .mvn/.develocity/develocity-workspace-id .sdkmanrc - -# flatten-maven-plugin -.flattened-pom.xml diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index da1e30302..ba1d827cd 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -272,6 +272,8 @@ ${flatten-maven-plugin.version} bom + + ${project.build.directory} remove