diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 2ce7b7f8ab..6945fb7109 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c48cf93c6..6686ee4c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,9 +8,34 @@ ### Version 13.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 + `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 `@Experimental` `MultiDecoder`, `PredicatedDecoder` and `DecoderPredicate`, the decode-side + counterpart, 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 `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 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 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/README.md b/README.md index cbaae10766..b45995fd9d 100644 --- a/README.md +++ b/README.md @@ -663,6 +663,116 @@ 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` 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 listed, in the +order they should be consulted: + +```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() + .decoders(new GsonDecoder(), new JAXBDecoder()) + .target(MixedClient.class, "https://foo.com"); + } +} +``` + +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. + +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() + .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(); +``` + +Decoders are consulted in the order they were added, so put the narrowest one first. + +##### Declaring your own decoder + +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 PredicatedDecoder { + + @Override + public boolean canDecode(Response response, Type type) { + return Util.isJsonContentType(response); + } + + @Override + public Object decode(Response response, Type type) throws IOException { + // ... + } +} +``` + +`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**, 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. @@ -709,6 +819,131 @@ 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` 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 listed, in the +order they should be consulted: + +```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() + .encoders(new GsonEncoder(), new JAXBEncoder()) + .target(MixedClient.class, "https://foo.com"); + } +} +``` + +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: + +``` +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() + .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(); +``` + +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` 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 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` 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()); +``` + +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/api/src/main/java/feign/BaseBuilder.java b/api/src/main/java/feign/BaseBuilder.java index 1bccd8f6a6..ba8ee41590 100644 --- a/api/src/main/java/feign/BaseBuilder.java +++ b/api/src/main/java/feign/BaseBuilder.java @@ -21,8 +21,14 @@ import feign.Request.Options; import feign.codec.Codec; import feign.codec.Decoder; +import feign.codec.DecoderPredicate; import feign.codec.Encoder; +import feign.codec.EncoderPredicate; import feign.codec.ErrorDecoder; +import feign.codec.MultiDecoder; +import feign.codec.MultiEncoder; +import feign.codec.PredicatedDecoder; +import feign.codec.PredicatedEncoder; import feign.interceptor.MethodInterceptor; import feign.interceptor.MethodInterceptors; import java.lang.reflect.Field; @@ -98,11 +104,73 @@ public B encoder(Encoder encoder) { return thisB(); } + /** + * Configures a {@link MultiEncoder} built from encoders that declare their own applicability. + * + *

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()
+   *     .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 PredicatedEncoder#of(EncoderPredicate, Encoder)} as above, or {@link + * MultiEncoder#builder()} for the same thing spelled out. + * + * @param encoders the predicated encoders, consulted in the order given + */ + @Experimental + public B encoders(PredicatedEncoder... encoders) { + MultiEncoder.Builder builder = MultiEncoder.builder(); + for (PredicatedEncoder encoder : encoders) { + builder.add(encoder); + } + return encoder(builder.build()); + } + public B decoder(Decoder decoder) { this.decoder = decoder; return thisB(); } + /** + * Configures a {@link MultiDecoder} built from decoders that declare their own applicability. + * + *

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()
+   *     .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 PredicatedDecoder#of(DecoderPredicate, Decoder)} as above, or {@link + * MultiDecoder#builder()} for the same thing spelled out. + * + * @param decoders the predicated decoders, consulted in the order given + */ + @Experimental + public B decoders(PredicatedDecoder... decoders) { + MultiDecoder.Builder builder = MultiDecoder.builder(); + 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/api/src/main/java/feign/Contract.java b/api/src/main/java/feign/Contract.java index dc040918a3..a1976d9f44 100644 --- a/api/src/main/java/feign/Contract.java +++ b/api/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); diff --git a/api/src/main/java/feign/ReflectiveFeign.java b/api/src/main/java/feign/ReflectiveFeign.java index bf3f265aab..14325cc893 100644 --- a/api/src/main/java/feign/ReflectiveFeign.java +++ b/api/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/api/src/main/java/feign/Util.java b/api/src/main/java/feign/Util.java index 639bb021a7..99d23be42a 100644 --- a/api/src/main/java/feign/Util.java +++ b/api/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,129 @@ 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 + */ + @Experimental + public static boolean isJsonContentType(RequestTemplate template) { + return hasContentTypeMatching(template, JSON_CONTENT_TYPE); + } + + /** + * 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 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 + */ + @Experimental + public static boolean isXmlContentType(RequestTemplate template) { + return hasContentTypeMatching(template, XML_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 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 matchesMediaType(contentTypes(template), mediaType); + } + + /** + * 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 matchesMediaType(contentTypes(response), mediaType); + } + + private static boolean matchesMediaType(Stream contentTypes, String mediaType) { + return contentTypes.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 contentTypes(template.headers()); + } + + private static Stream contentTypes(Response response) { + if (response == null || response.headers() == null) { + return Stream.empty(); + } + return contentTypes(response.headers()); + } + + private static Stream contentTypes(Map> headers) { + return 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(RequestTemplate template, Pattern pattern) { + return matchesPattern(contentTypes(template), pattern); + } + + private static boolean hasContentTypeMatching(Response response, Pattern pattern) { + return matchesPattern(contentTypes(response), pattern); + } + + private static boolean matchesPattern(Stream contentTypes, Pattern pattern) { + return contentTypes.anyMatch(contentType -> pattern.matcher(contentType.trim()).matches()); + } } diff --git a/api/src/main/java/feign/codec/DecoderPredicate.java b/api/src/main/java/feign/codec/DecoderPredicate.java new file mode 100644 index 0000000000..941d9cbab4 --- /dev/null +++ b/api/src/main/java/feign/codec/DecoderPredicate.java @@ -0,0 +1,154 @@ +/* + * 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. + * + *

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 + */ +@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); + + /** + * 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 describedAs( + "Content-Type is JSON", (response, type) -> Util.isJsonContentType(response)); + } + + /** Matches responses whose {@code Content-Type} header denotes XML. */ + static DecoderPredicate xmlContentType() { + return describedAs("Content-Type is XML", (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 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 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 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 describedAs( + "return type is " + expected.getTypeName(), (response, type) -> expected.equals(type)); + } + + default DecoderPredicate and(DecoderPredicate other) { + Objects.requireNonNull(other, "other cannot be null"); + 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 describedAs( + "(" + this + " or " + other + ")", + (response, type) -> canDecode(response, type) || other.canDecode(response, type)); + } + + default DecoderPredicate negate() { + return describedAs("not (" + this + ")", (response, type) -> !canDecode(response, type)); + } +} diff --git a/api/src/main/java/feign/codec/EncoderPredicate.java b/api/src/main/java/feign/codec/EncoderPredicate.java new file mode 100644 index 0000000000..150fd22ded --- /dev/null +++ b/api/src/main/java/feign/codec/EncoderPredicate.java @@ -0,0 +1,150 @@ +/* + * 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.RequestTemplate; +import feign.Util; +import java.lang.reflect.Type; +import java.util.Objects; + +/** + * 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 + * 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 + */ +@Experimental +@FunctionalInterface +public interface EncoderPredicate { + + /** + * 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} + * 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 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 describedAs( + "Content-Type is JSON", (object, bodyType, template) -> Util.isJsonContentType(template)); + } + + /** Matches requests whose {@code Content-Type} header denotes XML. */ + static EncoderPredicate xmlContentType() { + return describedAs( + "Content-Type is XML", (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 describedAs( + "Content-Type is " + mediaType, + (object, bodyType, template) -> Util.hasContentType(template, mediaType)); + } + + /** Matches requests carrying no body. */ + static EncoderPredicate emptyBody() { + 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 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 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 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 describedAs( + "(" + this + " or " + other + ")", + (object, bodyType, template) -> + canEncode(object, bodyType, template) || other.canEncode(object, bodyType, template)); + } + + default EncoderPredicate negate() { + return describedAs( + "not (" + this + ")", + (object, bodyType, template) -> !canEncode(object, bodyType, template)); + } +} diff --git a/api/src/main/java/feign/codec/MultiDecoder.java b/api/src/main/java/feign/codec/MultiDecoder.java new file mode 100644 index 0000000000..f48ee1975e --- /dev/null +++ b/api/src/main/java/feign/codec/MultiDecoder.java @@ -0,0 +1,165 @@ +/* + * 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 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 hands each response to the first decoder that accepts it. + * + *

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()
+ *             .add(new JacksonDecoder())
+ *             .add(DecoderPredicate.xmlContentType(), new JAXBDecoder())
+ *             .add((response, type) -> type == byte[].class, new BinaryDecoder())
+ *             .add(DecoderPredicate.any(), new DefaultDecoder())
+ *             .build());
+ * 
+ * + *

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 + */ +@Experimental +public class MultiDecoder implements Decoder { + + private final List decoders; + + private MultiDecoder(List decoders) { + this.decoders = Collections.unmodifiableList(new ArrayList<>(decoders)); + } + + /** Starts building a multi-decoder. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Decodes using the first decoder that accepts the response. + * + * @param response {@inheritDoc} + * @param type {@inheritDoc} + * @return {@inheritDoc} + * @throws IOException {@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 (PredicatedDecoder decoder : decoders) { + if (decoder.canDecode(response, type)) { + return decoder.decode(response, type); + } + } + throw new DecodeException( + response.status(), unableToDecode(response, type), response.request()); + } + + 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 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 "MultiDecoder" + + decoders.stream().map(PairedDecoder::describe).collect(Collectors.toList()); + } + + /** Collects the decoders of a {@link MultiDecoder}. */ + @Experimental + public static final class Builder { + + private final List decoders = new ArrayList<>(); + + private Builder() {} + + /** + * Adds a decoder that declares its own applicability. + * + * @param decoder the decoder, consulted via {@link PredicatedDecoder#canDecode} + */ + public Builder add(PredicatedDecoder decoder) { + decoders.add(Objects.requireNonNull(decoder, "decoder cannot be null")); + return this; + } + + /** + * 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) { + return add(PredicatedDecoder.of(predicate, decoder)); + } + + /** Builds the multi-decoder. */ + public MultiDecoder build() { + return new MultiDecoder(decoders); + } + } +} diff --git a/api/src/main/java/feign/codec/MultiEncoder.java b/api/src/main/java/feign/codec/MultiEncoder.java new file mode 100644 index 0000000000..23feaf35ba --- /dev/null +++ b/api/src/main/java/feign/codec/MultiEncoder.java @@ -0,0 +1,163 @@ +/* + * 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.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 hands each request to the first encoder that accepts it. + * + *

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()
+ *             .add(new JacksonEncoder())
+ *             .add(EncoderPredicate.xmlContentType(), new JAXBEncoder())
+ *             .add((object, bodyType, template) -> bodyType == byte[].class, new BinaryEncoder())
+ *             .add(EncoderPredicate.any(), new DefaultEncoder())
+ *             .build());
+ * 
+ * + *

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 + */ +@Experimental +public class MultiEncoder implements Encoder { + + private final List encoders; + + private MultiEncoder(List encoders) { + this.encoders = Collections.unmodifiableList(new ArrayList<>(encoders)); + } + + /** Starts building a multi-encoder. */ + public static Builder builder() { + return new Builder(); + } + + /** + * Encodes using the first encoder that accepts the request. + * + * @param object {@inheritDoc} + * @param bodyType {@inheritDoc} + * @param template {@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 (PredicatedEncoder encoder : encoders) { + if (encoder.canEncode(object, bodyType, template)) { + encoder.encode(object, bodyType, template); + return; + } + } + throw new EncodeException(unableToEncode(bodyType, template)); + } + + 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 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 "MultiEncoder" + + encoders.stream().map(PairedEncoder::describe).collect(Collectors.toList()); + } + + /** Collects the encoders of a {@link MultiEncoder}. */ + @Experimental + public static final class Builder { + + private final List encoders = new ArrayList<>(); + + private Builder() {} + + /** + * Adds an encoder that declares its own applicability. + * + * @param encoder the encoder, consulted via {@link PredicatedEncoder#canEncode} + */ + public Builder add(PredicatedEncoder encoder) { + encoders.add(Objects.requireNonNull(encoder, "encoder cannot be null")); + return this; + } + + /** + * 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) { + return add(PredicatedEncoder.of(predicate, encoder)); + } + + /** Builds the multi-encoder. */ + public MultiEncoder build() { + return new MultiEncoder(encoders); + } + } +} diff --git a/api/src/main/java/feign/codec/PairedDecoder.java b/api/src/main/java/feign/codec/PairedDecoder.java new file mode 100644 index 0000000000..f2922259bc --- /dev/null +++ b/api/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/api/src/main/java/feign/codec/PairedEncoder.java b/api/src/main/java/feign/codec/PairedEncoder.java new file mode 100644 index 0000000000..61a63f7636 --- /dev/null +++ b/api/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/api/src/main/java/feign/codec/PredicatedDecoder.java b/api/src/main/java/feign/codec/PredicatedDecoder.java new file mode 100644 index 0000000000..d8d48467a5 --- /dev/null +++ b/api/src/main/java/feign/codec/PredicatedDecoder.java @@ -0,0 +1,112 @@ +/* + * 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 PredicatedDecoder {
+ *
+ *   @Override
+ *   public boolean canDecode(Response response, Type type) {
+ *     return Util.isJsonContentType(response);
+ *   }
+ *
+ *   @Override
+ *   public Object decode(Response response, Type type) throws IOException {
+ *     // ...
+ *   }
+ * }
+ * 
+ * + *

{@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. + * + * @see MultiDecoder + * @see DecoderPredicate + */ +@Experimental +public interface PredicatedDecoder extends Decoder { + + /** + * 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. + * + * @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 + */ + boolean canDecode(Response response, Type type); +} diff --git a/api/src/main/java/feign/codec/PredicatedEncoder.java b/api/src/main/java/feign/codec/PredicatedEncoder.java new file mode 100644 index 0000000000..f9cd135acf --- /dev/null +++ b/api/src/main/java/feign/codec/PredicatedEncoder.java @@ -0,0 +1,110 @@ +/* + * 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.RequestTemplate; +import java.lang.reflect.Type; + +/** + * An {@link Encoder} that knows which requests it can handle. + * + *

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: + * + *

+ * public class JacksonEncoder implements 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) {
+ *     // ...
+ *   }
+ * }
+ * 
+ * + *

{@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. + * + * @see MultiEncoder + * @see EncoderPredicate + */ +@Experimental +public interface PredicatedEncoder extends Encoder { + + /** + * 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} + * indicates form encoding. + * @param template the request template to populate + * @return {@code true} if this encoder can encode the request, {@code false} otherwise + */ + boolean canEncode(Object object, Type bodyType, RequestTemplate template); +} diff --git a/api/src/main/java/feign/optionals/OptionalDecoder.java b/api/src/main/java/feign/optionals/OptionalDecoder.java index 475ee74b95..0edb3bedeb 100644 --- a/api/src/main/java/feign/optionals/OptionalDecoder.java +++ b/api/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/benchmark/pom.xml b/benchmark/pom.xml index 9b50a24cb9..d3e39e8da7 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 diff --git a/core/src/main/java/feign/core/DefaultClient.java b/core/src/main/java/feign/core/DefaultClient.java index 16821e66ad..26951dd529 100644 --- a/core/src/main/java/feign/core/DefaultClient.java +++ b/core/src/main/java/feign/core/DefaultClient.java @@ -181,7 +181,7 @@ public HttpURLConnection convertAndSend(Request request, Options options) throws 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); } @@ -202,12 +202,7 @@ else if (field.equals(ACCEPT_ENCODING)) { Optional body = request.body(); - if (body.isPresent() - && (body.get().contentLength() != 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.isPresent() && body.get().contentLength() != 0) { if (disableRequestBuffering) { if (contentLength != null) { connection.setFixedLengthStreamingMode(contentLength); @@ -230,10 +225,15 @@ else if (field.equals(ACCEPT_ENCODING)) { } catch (IOException suppressed) { // NOPMD } } - } - - if (body.isEmpty() && 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/BridgeMethodTest.java b/core/src/test/java/feign/BridgeMethodTest.java new file mode 100644 index 0000000000..d7e2802058 --- /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 feign.core.DefaultContract; +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 DefaultContract().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"); + } +} diff --git a/core/src/test/java/feign/client/AbstractClientTest.java b/core/src/test/java/feign/client/AbstractClientTest.java index 73bf6ec8fe..df29c691c0 100644 --- a/core/src/test/java/feign/client/AbstractClientTest.java +++ b/core/src/test/java/feign/client/AbstractClientTest.java @@ -231,6 +231,16 @@ public void noResponseBodyForPut() throws Exception { api.noPutBody(); } + @Test + public void emptyStringBodyForPost() throws Exception { + server.enqueue(new MockResponse.Builder().build()); + + 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. @@ -619,6 +629,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/codec/DecoderPredicateTest.java b/core/src/test/java/feign/codec/DecoderPredicateTest.java new file mode 100644 index 0000000000..db94f3aece --- /dev/null +++ b/core/src/test/java/feign/codec/DecoderPredicateTest.java @@ -0,0 +1,128 @@ +/* + * 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, 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(); + } +} 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 0000000000..5028567d6a --- /dev/null +++ b/core/src/test/java/feign/codec/EncoderPredicateTest.java @@ -0,0 +1,148 @@ +/* + * 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 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(); + + 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 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(); + 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/MultiDecoderCapabilityTest.java b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java new file mode 100644 index 0000000000..b5af2c58e5 --- /dev/null +++ b/core/src/test/java/feign/codec/MultiDecoderCapabilityTest.java @@ -0,0 +1,237 @@ +/* + * 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.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, 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() + .add(DecoderPredicate.jsonContentType(), new TaggingDecoder("json")) + .add(DecoderPredicate.xmlContentType(), new TaggingDecoder("xml")) + .add(DecoderPredicate.any(), new TaggingDecoder("fallback")) + .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 decodersOnTheBuilderRouteInTheOrderGiven() { + Map contentTypes = new HashMap<>(); + contentTypes.put("json", "application/json"); + contentTypes.put("csv", "text/csv"); + + MixedApi api = + target( + 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() + .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 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 = + 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() { + @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() + .add(naive) + .add(DecoderPredicate.any(), new TaggingDecoder("fallback")) + .build() + .decode(response("application/xml", "payload"), String.class)) + .isEqualTo("json"); + + assertThat( + 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 new file mode 100644 index 0000000000..d6732bc22b --- /dev/null +++ b/core/src/test/java/feign/codec/MultiDecoderTest.java @@ -0,0 +1,310 @@ +/* + * 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, 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().add(json).add(DecoderPredicate.any(), fallback).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() + .add(DecoderPredicate.xmlContentType(), xml) + .add(DecoderPredicate.any(), fallback) + .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() + .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)) + .isEqualTo("csv"); + assertThat(json.invoked).isFalse(); + assertThat(xml.invoked).isFalse(); + assertThat(fallback.invoked).isFalse(); + } + + @Test + void matchesSuffixedContentTypes() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + + Decoder decoder = MultiDecoder.builder().add(json).build(); + + assertThat(decoder.decode(responseWithContentType("application/vnd.github+json"), String.class)) + .isEqualTo("json"); + } + + @Test + void fallsBackToTheDecoderThatAcceptsAnything() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + RecordingDecoder fallback = new RecordingDecoder("fallback"); + + Decoder decoder = + MultiDecoder.builder().add(json).add(DecoderPredicate.any(), fallback).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().add(json).add(DecoderPredicate.any(), fallback).build(); + + assertThat(decoder.decode(responseWithContentType(null), String.class)).isEqualTo("fallback"); + } + + @Test + void consultsDecodersInTheOrderTheyWereAdded() throws IOException { + RecordingDecoder first = new RecordingDecoder("first"); + RecordingDecoder second = new RecordingDecoder("second"); + + Decoder decoder = + MultiDecoder.builder() + .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 pairingReplacesWhatTheDecoderDeclaresAboutItself() throws IOException { + SelfDeclaringJsonDecoder json = new SelfDeclaringJsonDecoder(); + + Decoder decoder = + MultiDecoder.builder().add(PredicatedDecoder.of(DecoderPredicate.any(), json)).build(); + + assertThat(decoder.decode(responseWithContentType("text/plain"), String.class)) + .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 + void propagatesIoExceptionsFromTheSelectedDecoder() { + Decoder failing = + (response, type) -> { + throw new IOException("boom"); + }; + + Decoder decoder = + MultiDecoder.builder().add(DecoderPredicate.jsonContentType(), failing).build(); + + assertThatThrownBy( + () -> decoder.decode(responseWithContentType("application/json"), String.class)) + .isInstanceOf(IOException.class) + .hasMessage("boom"); + } + + @Test + void rejectsNullDecoders() { + MultiDecoder.Builder builder = MultiDecoder.builder(); + + 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 describesItsDecoders() { + Decoder decoder = + MultiDecoder.builder() + .add(new SelfDeclaringJsonDecoder()) + .add(DecoderPredicate.jsonContentType(), new RecordingDecoder("json")) + .build(); + + assertThat(decoder.toString()) + .isEqualTo( + "MultiDecoder[SelfDeclaringJsonDecoder, RecordingDecoder when Content-Type is JSON]"); + } +} 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 0000000000..941dfa5337 --- /dev/null +++ b/core/src/test/java/feign/codec/MultiEncoderCapabilityTest.java @@ -0,0 +1,232 @@ +/* + * 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.Capability; +import feign.Feign; +import feign.Headers; +import feign.Request; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Response; +import feign.Util; +import java.io.IOException; +import java.io.UncheckedIOException; +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 once configured on a {@link Feign} builder. */ +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(Request.Body.of(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(request.body().get().writeToString(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 String bodyOf(RequestTemplate template) { + try { + return template.requestBody().get().writeToString(Util.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static RequestTemplate template(String contentType) { + RequestTemplate template = new RequestTemplate(); + template.header("Content-Type", 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(); + AtomicReference captured = new AtomicReference<>(); + + MixedApi api = + target( + Feign.builder() + .encoder( + MultiEncoder.builder() + .add(EncoderPredicate.jsonContentType(), new TaggingEncoder("json")) + .add(EncoderPredicate.xmlContentType(), new TaggingEncoder("xml")) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) + .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 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() { + 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(Request.Body.of("json")); + } + }; + + 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() { + @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() + .add(naive) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) + .build() + .encode("body", String.class, naiveTemplate); + assertThat(bodyOf(naiveTemplate)).isEqualTo("json"); + + RequestTemplate forwardedTemplate = template("application/xml"); + MultiEncoder.builder() + .add(forwarding) + .add(EncoderPredicate.any(), new TaggingEncoder("fallback")) + .build() + .encode("body", String.class, forwardedTemplate); + assertThat(bodyOf(forwardedTemplate)).isEqualTo("fallback"); + } +} 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 0000000000..a1a223d59c --- /dev/null +++ b/core/src/test/java/feign/codec/MultiEncoderTest.java @@ -0,0 +1,331 @@ +/* + * 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 feign.Util; +import feign.core.codec.DefaultEncoder; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.lang.reflect.Type; +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; + + RecordingEncoder(String body) { + this.body = body; + } + + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + invoked = true; + template.body(Request.Body.of(body)); + } + } + + /** 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 String bodyOf(RequestTemplate template) { + try { + return template.requestBody().get().writeToString(Util.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private static RequestTemplate templateWithContentType(String contentType) { + RequestTemplate template = new RequestTemplate(); + if (contentType != null) { + template.header("Content-Type", contentType); + } + return template; + } + + @Test + void routesToTheEncoderThatDeclaresItCanHandleTheRequest() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); + + RequestTemplate template = templateWithContentType("application/json"); + encoder.encode("body", String.class, template); + + assertThat(json.invoked).isTrue(); + assertThat(fallback.invoked).isFalse(); + assertThat(bodyOf(template)).isEqualTo("json"); + } + + @Test + void pairsAPredicateWithAnEncoderThatDoesNotDeclareItself() { + RecordingEncoder xml = new RecordingEncoder("xml"); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder() + .add(EncoderPredicate.xmlContentType(), xml) + .add(EncoderPredicate.any(), fallback) + .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() + .add(json) + .add(EncoderPredicate.xmlContentType(), xml) + .add(EncoderPredicate.bodyType(byte[].class), binary) + .add(EncoderPredicate.any(), fallback) + .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() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + + Encoder encoder = MultiEncoder.builder().add(json).build(); + + encoder.encode("body", String.class, templateWithContentType("application/vnd.github+json")); + + assertThat(json.invoked).isTrue(); + } + + @Test + void fallsBackToTheEncoderThatAcceptsAnything() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); + + RequestTemplate template = templateWithContentType("text/plain"); + encoder.encode("body", String.class, template); + + assertThat(json.invoked).isFalse(); + assertThat(fallback.invoked).isTrue(); + assertThat(bodyOf(template)).isEqualTo("fallback"); + } + + @Test + void fallsBackWhenNoContentTypeIsSet() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + RecordingEncoder fallback = new RecordingEncoder("fallback"); + + Encoder encoder = + MultiEncoder.builder().add(json).add(EncoderPredicate.any(), fallback).build(); + + encoder.encode("body", String.class, templateWithContentType(null)); + + assertThat(fallback.invoked).isTrue(); + } + + @Test + void encodersAreConsultedInOrder() { + RecordingEncoder first = new RecordingEncoder("first"); + RecordingEncoder second = new RecordingEncoder("second"); + + Encoder encoder = + MultiEncoder.builder() + .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 pairingReplacesWhatTheEncoderDeclaresAboutItself() { + SelfDeclaringJsonEncoder json = new SelfDeclaringJsonEncoder(); + + Encoder encoder = + MultiEncoder.builder().add(PredicatedEncoder.of(EncoderPredicate.any(), json)).build(); + + encoder.encode("body", String.class, templateWithContentType("text/plain")); + + 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 + void propagatesEncodeExceptionFromDelegate() { + Encoder failing = + (object, bodyType, template) -> { + throw new EncodeException("boom"); + }; + + Encoder encoder = + MultiEncoder.builder().add(EncoderPredicate.jsonContentType(), failing).build(); + + assertThatThrownBy( + () -> encoder.encode("body", String.class, templateWithContentType("application/json"))) + .isInstanceOf(EncodeException.class) + .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().add(null)) + .isInstanceOf(NullPointerException.class) + .hasMessage("encoder cannot be null"); + 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 toStringDescribesEncoders() { + Encoder encoder = + MultiEncoder.builder() + .add(new SelfDeclaringJsonEncoder()) + .add(EncoderPredicate.jsonContentType(), new RecordingEncoder("json")) + .build(); + + assertThat(encoder.toString()) + .isEqualTo( + "MultiEncoder[SelfDeclaringJsonEncoder, RecordingEncoder when Content-Type is JSON]"); + } +} diff --git a/core/src/test/java/feign/core/DefaultClientTest.java b/core/src/test/java/feign/core/DefaultClientTest.java index 0e236773e6..e41e9252de 100644 --- a/core/src/test/java/feign/core/DefaultClientTest.java +++ b/core/src/test/java/feign/core/DefaultClientTest.java @@ -42,6 +42,7 @@ import java.util.Map; import java.util.zip.GZIPOutputStream; import mockwebserver3.MockResponse; +import mockwebserver3.RecordedRequest; import okio.Buffer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfSystemProperty; @@ -145,6 +146,45 @@ void noRequestBodyForPostWithAllowRestrictedHeaders() throws Exception { .hasHeaders(entry("Content-Length", Collections.singletonList("0"))); } + @Test + void lowerCaseContentLengthHeaderIsUsedForFixedLengthStreamingMode() throws Exception { + server.enqueue(new MockResponse.Builder().build()); + 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, + Request.Body.of(body), + 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.getHeaders().get("Transfer-Encoding")).isNull(); + } + + @Test + @EnabledIfSystemProperty(named = "sun.net.http.allowRestrictedHeaders", matches = "true") + public void contentLengthHeaderIsNotDuplicatedForBodylessRequest() throws Exception { + server.enqueue(new MockResponse.Builder().build()); + Map> headers = new LinkedHashMap<>(); + headers.put("content-length", Collections.singletonList("0")); + Request request = + Request.create( + HttpMethod.POST, "http://localhost:" + server.getPort() + "/", headers, null, 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.Builder().body("foo").build()); @@ -161,6 +201,15 @@ void emptyBodyDoesNotConvertGetToPost() throws Exception { MockWebServerAssertions.assertThat(server.takeRequest()).hasMethod("GET"); } + @Test + @Override + public void emptyStringBodyForPost() throws Exception { + super.emptyStringBodyForPost(); + MockWebServerAssertions.assertThat(server.takeRequest()) + .hasMethod("POST") + .hasNoHeaderNamed("Content-Type"); + } + @Test @Override public void noResponseBodyForPut() throws Exception { diff --git a/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredDecoder.java index e47fbe852d..b51d154b27 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-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java b/dropwizard-metrics4/src/main/java/feign/metrics4/MeteredEncoder.java index 4263d14d51..4cb46daa28 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; @@ -61,4 +62,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template) metricSuppliers.histograms()) .update(body.contentLength())); } + + @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/MeteredDecoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredDecoder.java index 653d29e62e..5f56b57701 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/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java b/dropwizard-metrics5/src/main/java/feign/metrics5/MeteredEncoder.java index 09f3058be4..3f89af5f09 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; @@ -73,4 +74,10 @@ public void encode(Object object, Type bodyType, RequestTemplate template) metricSuppliers.histograms()) .update(body.contentLength())); } + + @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/Fastjson2Decoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Decoder.java index 80b1ada8d7..e8631ae0c5 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/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java index 7e79af331a..42ec2938aa 100644 --- a/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java +++ b/fastjson2/src/main/java/feign/fastjson2/Fastjson2Encoder.java @@ -19,15 +19,17 @@ import com.alibaba.fastjson2.JSONWriter; import feign.Request; import feign.RequestTemplate; +import feign.Util; 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 +46,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException { template.body(Request.Body.of(JSON.toJSONBytes(object, features))); } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/feign-bom/pom.xml b/feign-bom/pom.xml index f54b8d76b8..20757486e0 100644 --- a/feign-bom/pom.xml +++ b/feign-bom/pom.xml @@ -267,4 +267,49 @@ + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + bom + + ${project.build.directory} + + remove + + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + com.github.ekryd.sortpom + sortpom-maven-plugin + + + ${project.basedir}/pom.xml + + + + + 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 e177767c19..d30b6f4522 100644 --- a/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java +++ b/form-spring/src/main/java/feign/form/spring/SpringFormEncoder.java @@ -21,6 +21,7 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import feign.core.codec.DefaultEncoder; import feign.form.FormEncoder; import feign.form.MultipartFormContentProcessor; @@ -41,10 +42,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 deb4656733..5ffc023a3b 100644 --- a/form/src/main/java/feign/form/FormEncoder.java +++ b/form/src/main/java/feign/form/FormEncoder.java @@ -24,6 +24,8 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.EncoderPredicate; +import feign.codec.PredicatedEncoder; import feign.core.codec.DefaultEncoder; import java.lang.reflect.Type; import java.nio.charset.Charset; @@ -47,6 +49,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\\-]+)"); @@ -64,13 +76,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; final var 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) { @@ -78,6 +93,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) @@ -114,7 +160,7 @@ public final ContentProcessor getContentProcessor(ContentType type) { } @SuppressWarnings("PMD.AvoidBranchingStatementAsLastInLoop") - private String getContentTypeValue(Map> headers) { + private static String getContentTypeValue(Map> headers) { for (var 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 0000000000..68c54874df --- /dev/null +++ b/form/src/test/java/feign/form/PredicatedFormEncoderTest.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.form; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import feign.Request; +import feign.RequestTemplate; +import feign.Util; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.EncoderPredicate; +import feign.codec.MultiEncoder; +import feign.codec.PredicatedEncoder; +import java.io.IOException; +import java.io.UncheckedIOException; +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 String bodyOf(RequestTemplate template) { + try { + return template.requestBody().get().writeToString(Util.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + 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(bodyOf(template)).isEqualTo("foo=bar"); + } + + @Test + void routesAlongsideOtherEncoders() { + Encoder json = (object, bodyType, template) -> template.body(Request.Body.of("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(bodyOf(form)).isEqualTo("foo=bar"); + + RequestTemplate other = template("application/json"); + encoder.encode("body", String.class, other); + assertThat(bodyOf(other)).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"); + } +} diff --git a/graphql-apt/README.md b/graphql-apt/README.md index cc6935233b..dc0b27cb48 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 2ea251ca80..c69b19a8d0 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( @@ -358,12 +385,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 +406,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 +422,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-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java b/graphql-apt/src/test/java/feign/graphql/apt/GraphqlSchemaProcessorTest.java index e4488eed11..3bd7b9bc18 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 6f9ba883e2..6acfcf6019 100644 --- a/graphql/README.md +++ b/graphql/README.md @@ -100,6 +100,89 @@ 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(...)`. 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 +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: @@ -132,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/GraphqlCapability.java b/graphql/src/main/java/feign/graphql/GraphqlCapability.java index 9ace756bd0..3b0970e55e 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,7 +25,11 @@ import feign.codec.JsonCodec; import feign.codec.JsonDecoder; import feign.codec.JsonEncoder; +import java.time.Duration; import java.util.ArrayList; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; @Experimental public class GraphqlCapability implements Capability { @@ -33,15 +38,70 @@ 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); + } + + /** + * @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); + } + + /** + * @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); + this.graphqlDecoder = new GraphqlDecoder(decoder, eventTimeout, executor); this.interceptor = new GraphqlRequestInterceptor(encoder, contract); + this.jsonEncoder = encoder; + this.jsonDecoder = decoder; + } + + /** + * 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 Executors.newCachedThreadPool( + runnable -> { + var thread = + new Thread(runnable, "feign-graphql-subscription-" + threads.incrementAndGet()); + thread.setDaemon(true); + return thread; + }); } @Override @@ -59,6 +119,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 cb77db3c21..c5f3988346 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 78d8d25efe..353d84fd3c 100644 --- a/graphql/src/main/java/feign/graphql/GraphqlDecoder.java +++ b/graphql/src/main/java/feign/graphql/GraphqlDecoder.java @@ -21,25 +21,53 @@ 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.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; @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; + private final Executor executor; public GraphqlDecoder(JsonDecoder jsonDecoder) { + this(jsonDecoder, DEFAULT_EVENT_TIMEOUT, Runnable::run); + } + + 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 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) { @@ -67,11 +95,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"); @@ -80,13 +113,17 @@ private Object doDecode(Response response, Type type) throws IOException { } 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); } @@ -102,7 +139,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; @@ -112,14 +149,14 @@ private String resolveOperationField(Map root, Response response } } - if (response.request() != null && response.request().body().isPresent()) { + if (request != null && request.body().isPresent()) { try { var fakeResponse = Response.builder() .status(200) .headers(Collections.emptyMap()) - .request(response.request()) - .body(bodyAsByteArray(response.request())) + .request(request) + .body(bodyAsByteArray(request)) .build(); var requestBody = (Map) jsonDecoder.decode(fakeResponse, Map.class); if (requestBody != null) { @@ -142,6 +179,118 @@ private byte[] bodyAsByteArray(Request request) throws IOException { return body.isPresent() ? body.get().writeToByteArray() : null; } + /** + * 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<>(); + // Cancelling must reach the socket, or a cancelled future leaks the connection and its worker. + future.whenComplete( + (ignored, error) -> { + if (future.isCancelled()) { + subscription.unsubscribe(); + } + }); + 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; + } + + 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) { + // 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. + return subscriber -> { + publisher.subscribe(subscriber); + if (!started.compareAndSet(false, true)) { + return; + } + 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); + } + }); + } catch (RejectedExecutionException e) { + // A subscriber must always get a terminal signal; stranding it is worse than failing it. + subscription.unsubscribe(); + publisher.closeExceptionally(e); + } + }; + } + + 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 0000000000..5248752ade --- /dev/null +++ b/graphql/src/main/java/feign/graphql/GraphqlSubscriptionClient.java @@ -0,0 +1,509 @@ +/* + * 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.InterruptedIOException; +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(); + + /** 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; + 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 final AtomicBoolean unsubscribed = 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; + + this.operation = + request.body().isEmpty() + ? new Operation(meta.query, Map.of()) + : decode(request.body().get().writeToString(UTF_8), 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) { + 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); + } + return null; + } + + @Override + public void onError(WebSocket ws, Throwable error) { + publish(error); + } + + @Override + public CompletionStage onClose(WebSocket ws, int statusCode, String reason) { + publish(DONE); + return null; + } + + /** + * @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) { + return publish(e); + } + if (message == null || (message.id() != null && !message.id().equals(operationId))) { + return false; + } + + return switch (message.type()) { + case "connection_ack" -> { + send(ws, new ClientMessage.Subscribe(operationId, "subscribe", operation)); + yield false; + } + case "next" -> publish(message.payloadFields()); + case "error" -> + publish( + new GraphqlErrorException( + HttpURLConnection.HTTP_OK, + GraphqlContract.extractOperationField(meta.query), + String.valueOf(message.payload()), + request)); + 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 { + 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. 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)) + .handle( + (ignored, error) -> { + if (error != null) { + publish(error); + } + return null; + }); + } + + private String toJson(ClientMessage message) { + var template = new RequestTemplate(); + jsonEncoder.encode(message, message.getClass(), template); + try { + return template.requestBody().get().writeToString(UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + void unsubscribe() { + if (!unsubscribed.compareAndSet(false, true)) { + return; + } + var ws = webSocket; + publish(DONE); + if (ws == null) { + return; + } + 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, "")); + } + } + + @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 { + 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 + } + } + return event; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new InterruptedIOException("interrupted awaiting a GraphQL subscription event"); + } + } + } + + 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/GraphqlClientTest.java b/graphql/src/test/java/feign/graphql/GraphqlClientTest.java index 72fb9fa6ed..d73549cc54 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 @@ -222,6 +234,28 @@ void optionalReturnTypeEmptyWhenNull() throws Exception { assertThat(user).isEmpty(); } + @Test + void multipleRootFieldsDecodedIntoSingleResult() throws Exception { + server.enqueue( + new MockResponse.Builder() + .body( + "{\"data\":{\"books\":[{\"id\":\"1\",\"title\":\"Dune\"}]," + + "\"reviews\":[{\"id\":\"9\",\"rating\":5}]}}") + .addHeader("Content-Type", "application/json") + .build()); + + 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().utf8()); + 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 3f94947818..29563b68fd 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) 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 0000000000..135ca2fc37 --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionConcurrencyTest.java @@ -0,0 +1,390 @@ +/* + * 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.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; +import java.util.stream.Stream; +import mockwebserver3.Dispatcher; +import mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import mockwebserver3.RecordedRequest; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +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(); + + /** + * 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; + } + + 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); + + @GraphqlQuery( + "subscription onPrice($symbol: String!) {" + + " priceChanged(symbol: $symbol) { symbol price } }") + CompletableFuture futurePrice(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.Builder().webSocketUpgrade(new EchoingServer()).build(); + } + }); + server.start(); + } + + @AfterEach + void tearDown() throws Exception { + server.close(); + } + + /** 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; + } + 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++) { + 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 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 { + 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 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(); + + 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(); + } +} 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 0000000000..30218f179e --- /dev/null +++ b/graphql/src/test/java/feign/graphql/GraphqlSubscriptionTest.java @@ -0,0 +1,466 @@ +/* + * 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 mockwebserver3.MockResponse; +import mockwebserver3.MockWebServer; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +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.close(); + } + + 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.Builder() + .webSocketUpgrade( + 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(); + } + }) + .build()); + } + + @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.Builder() + .body("{\"data\":{\"lastPrice\":{\"symbol\":\"ACME\",\"price\":9.75}}}") + .addHeader("Content-Type", "application/json") + .build()); + 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.getHeaders().get("Upgrade")).isNull(); + + var handshake = server.takeRequest(); + assertThat(handshake.getHeaders().get("Upgrade")).isEqualToIgnoringCase("websocket"); + assertThat(handshake.getHeaders().get("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 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)); + } + + @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"); + } +} diff --git a/gson/src/main/java/feign/gson/GsonDecoder.java b/gson/src/main/java/feign/gson/GsonDecoder.java index 5fa6ee0369..8a908087c1 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/gson/src/main/java/feign/gson/GsonEncoder.java b/gson/src/main/java/feign/gson/GsonEncoder.java index c0c3d0eb2f..6732670295 100644 --- a/gson/src/main/java/feign/gson/GsonEncoder.java +++ b/gson/src/main/java/feign/gson/GsonEncoder.java @@ -19,12 +19,14 @@ import com.google.gson.TypeAdapter; import feign.Request; 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; @@ -44,4 +46,9 @@ public GsonEncoder(Gson gson) { public void encode(Object object, Type bodyType, RequestTemplate template) { template.body(Request.Body.of(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/JacksonJaxbJsonDecoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonDecoder.java index ed1cb12a2c..98a3e0e833 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-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java index 6e4caeaede..384ed20a18 100644 --- a/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java +++ b/jackson-jaxb/src/main/java/feign/jackson/jaxb/JacksonJaxbJsonEncoder.java @@ -22,13 +22,15 @@ import com.fasterxml.jackson.jaxrs.json.JacksonJaxbJsonProvider; import feign.Request; 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; -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/JacksonJrDecoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrDecoder.java index 3edb1f8dda..7d4581f5f2 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-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java index 26fe78179c..6f4665cea5 100644 --- a/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java +++ b/jackson-jr/src/main/java/feign/jackson/jr/JacksonJrEncoder.java @@ -19,13 +19,15 @@ import com.fasterxml.jackson.jr.ob.JacksonJrExtension; import feign.Request; 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(); @@ -62,4 +64,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/JacksonDecoder.java b/jackson/src/main/java/feign/jackson/JacksonDecoder.java index 370f745dc7..db5a0dbe58 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/jackson/src/main/java/feign/jackson/JacksonEncoder.java b/jackson/src/main/java/feign/jackson/JacksonEncoder.java index 280cd3bb58..348806c274 100644 --- a/jackson/src/main/java/feign/jackson/JacksonEncoder.java +++ b/jackson/src/main/java/feign/jackson/JacksonEncoder.java @@ -23,13 +23,15 @@ import com.fasterxml.jackson.databind.SerializationFeature; import feign.Request; import feign.RequestTemplate; +import feign.Util; 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 +60,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/Jackson3Decoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Decoder.java index 5726d582bd..363b17287e 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/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java index 273e6d8f79..00d52da27d 100644 --- a/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java +++ b/jackson3/src/main/java/feign/jackson3/Jackson3Encoder.java @@ -18,9 +18,11 @@ import com.fasterxml.jackson.annotation.JsonInclude; import feign.Request; import feign.RequestTemplate; +import feign.Util; 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 +31,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 +62,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/JAXBContextFactory.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBContextFactory.java index 07918bc6ce..36692a6f9f 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/main/java/feign/jaxb/JAXBDecoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBDecoder.java index 6a40861cd1..feaf8237b5 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-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java index a25845800b..3e9a768b7d 100644 --- a/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb-jakarta/src/main/java/feign/jaxb/JAXBEncoder.java @@ -17,8 +17,10 @@ import feign.Request; 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; @@ -43,7 +45,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; @@ -66,4 +68,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-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java index 7410696700..68b90a981e 100644 --- a/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb-jakarta/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -28,6 +28,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; @@ -39,10 +40,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") @@ -209,6 +213,53 @@ 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, null)) + .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 87008e6ab1..fee68a88c9 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 3f057d8c0d..dcd7f6c248 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/main/java/feign/jaxb/JAXBDecoder.java b/jaxb/src/main/java/feign/jaxb/JAXBDecoder.java index 9d998d26dc..a7132472ec 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/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java index 4c05d82e71..c3baa91d56 100644 --- a/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java +++ b/jaxb/src/main/java/feign/jaxb/JAXBEncoder.java @@ -17,8 +17,10 @@ import feign.Request; 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; @@ -43,7 +45,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; @@ -66,4 +68,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/test/java/feign/jaxb/JAXBCodecTest.java b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java index 97bc514d0a..2b1395b033 100644 --- a/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java +++ b/jaxb/src/test/java/feign/jaxb/JAXBCodecTest.java @@ -19,6 +19,7 @@ import static feign.assertj.FeignAssertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import com.sun.xml.bind.IDResolver; import feign.Request; import feign.Request.HttpMethod; import feign.RequestTemplate; @@ -33,6 +34,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; @@ -209,6 +211,53 @@ 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, null)) + .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 959836849c..39edfead6b 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(); diff --git a/json/src/main/java/feign/json/JsonDecoder.java b/json/src/main/java/feign/json/JsonDecoder.java index edf7fd80f0..5f0a264869 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/json/src/main/java/feign/json/JsonEncoder.java b/json/src/main/java/feign/json/JsonEncoder.java index 1ef3cd6fb1..0d9118c70f 100644 --- a/json/src/main/java/feign/json/JsonEncoder.java +++ b/json/src/main/java/feign/json/JsonEncoder.java @@ -19,8 +19,10 @@ import feign.Request; 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; @@ -52,7 +54,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) @@ -64,4 +66,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/MeteredDecoder.java b/micrometer/src/main/java/feign/micrometer/MeteredDecoder.java index 65b8067ebc..926452254e 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/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java index 784aa86c3b..16b9b0e364 100644 --- a/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java +++ b/micrometer/src/main/java/feign/micrometer/MeteredEncoder.java @@ -21,6 +21,7 @@ import feign.RequestTemplate; import feign.codec.EncodeException; import feign.codec.Encoder; +import feign.codec.PredicatedEncoder; import io.micrometer.core.instrument.DistributionSummary; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Tag; @@ -30,7 +31,7 @@ import java.util.Collections; /** 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; @@ -87,4 +88,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/MoshiDecoder.java b/moshi/src/main/java/feign/moshi/MoshiDecoder.java index ac08ee96a4..9f4e006f42 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/moshi/src/main/java/feign/moshi/MoshiEncoder.java b/moshi/src/main/java/feign/moshi/MoshiEncoder.java index fdd7507741..8ad8939e19 100644 --- a/moshi/src/main/java/feign/moshi/MoshiEncoder.java +++ b/moshi/src/main/java/feign/moshi/MoshiEncoder.java @@ -19,11 +19,13 @@ import com.squareup.moshi.Moshi; import feign.Request; 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; @@ -44,4 +46,9 @@ public void encode(Object object, Type bodyType, RequestTemplate template) { JsonAdapter jsonAdapter = moshi.adapter(bodyType).indent(" "); template.body(Request.Body.of(jsonAdapter.toJson(object))); } + + @Override + public boolean canEncode(Object object, Type bodyType, RequestTemplate template) { + return Util.isJsonContentType(template); + } } diff --git a/pom.xml b/pom.xml index 8555d8fb51..e87728c696 100644 --- a/pom.xml +++ b/pom.xml @@ -165,21 +165,21 @@ ${main.java.version} ${main.java.version} - 5.4.0 - 33.6.0-jre + 5.5.0 + 33.7.1-jre 2.2.0 2.14.0 1.15.2 2.0.18 - 20260719 + 20260814 4.1.0 - 6.1.2 - 2.22.1 - 3.2.1 + 6.1.3 + 2.22.2 + 3.2.2 3.27.7 5.23.0 - 2.0.63.android8 + 2.0.64.android8 1.5.3 6.0 @@ -206,9 +206,10 @@ 3.3.0 1.2.8 4.0.0 - 6.45.0 - 3.43.0 - 3.41.0 + 1.8.0 + 6.46.1 + 3.44.0 + 3.42.0 0.26.1 1.0 @@ -223,7 +224,7 @@ 26.0 4.5.4 4.5.14 - 5.6.3 + 5.6.4 1.13.0 1.5.18 3.1.0 diff --git a/sax/src/main/java/feign/sax/SAXDecoder.java b/sax/src/main/java/feign/sax/SAXDecoder.java index 6aa799d0a2..20da34bea1 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 37386f0268..619ce01d7f 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-jakarta/src/main/java/feign/soap/SOAPEncoder.java b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java index 31bc768304..92a30913aa 100644 --- a/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap-jakarta/src/main/java/feign/soap/SOAPEncoder.java @@ -17,8 +17,10 @@ import feign.Request; 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; @@ -83,7 +85,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; @@ -225,4 +227,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/SOAPDecoder.java b/soap/src/main/java/feign/soap/SOAPDecoder.java index 8079a622a4..bf63ba4c27 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); + } } diff --git a/soap/src/main/java/feign/soap/SOAPEncoder.java b/soap/src/main/java/feign/soap/SOAPEncoder.java index 711c44171f..995dc70b8e 100644 --- a/soap/src/main/java/feign/soap/SOAPEncoder.java +++ b/soap/src/main/java/feign/soap/SOAPEncoder.java @@ -17,8 +17,10 @@ import feign.Request; 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; @@ -83,7 +85,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; @@ -225,4 +227,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/config/bom.xml b/src/config/bom.xml index a7f2fdab3b..30ee18a64f 100644 --- a/src/config/bom.xml +++ b/src/config/bom.xml @@ -65,4 +65,47 @@ + + + + + org.codehaus.mojo + flatten-maven-plugin + ${flatten-maven-plugin.version} + + bom + + remove + + + + + flatten + + flatten + + process-resources + + + flatten.clean + + clean + + clean + + + + + com.github.ekryd.sortpom + sortpom-maven-plugin + + + ${project.basedir}/pom.xml + + + + + diff --git a/src/docs/overview-mindmap.iuml b/src/docs/overview-mindmap.iuml index afd6aefbf3..e618f55064 100644 --- a/src/docs/overview-mindmap.iuml +++ b/src/docs/overview-mindmap.iuml @@ -1,63 +1,65 @@ -@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, experimental) +*** Multi decoder (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 diff --git a/vertx/feign-vertx/pom.xml b/vertx/feign-vertx/pom.xml index ed81465284..47c21ef8b9 100644 --- a/vertx/feign-vertx/pom.xml +++ b/vertx/feign-vertx/pom.xml @@ -31,7 +31,7 @@ 11 - 2.22.1 + 2.22.2 diff --git a/vertx/feign-vertx4-test/pom.xml b/vertx/feign-vertx4-test/pom.xml index 549e79fb33..38581958eb 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 diff --git a/vertx/feign-vertx5-test/pom.xml b/vertx/feign-vertx5-test/pom.xml index f24c15a1ac..e574a69db3 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