diff --git a/java11/pom.xml b/java11/pom.xml index 6188ed12f..69c5277ba 100644 --- a/java11/pom.xml +++ b/java11/pom.xml @@ -69,6 +69,11 @@ jar test + + com.google.code.gson + gson + test + diff --git a/java11/src/main/java/feign/http2client/Http2Client.java b/java11/src/main/java/feign/http2client/Http2Client.java index a68871c79..99664b480 100644 --- a/java11/src/main/java/feign/http2client/Http2Client.java +++ b/java11/src/main/java/feign/http2client/Http2Client.java @@ -1,5 +1,5 @@ /** - * Copyright 2012-2020 The Feign Authors + * Copyright 2012-2021 The Feign Authors * *

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 @@ -13,6 +13,7 @@ */ package feign.http2client; +import feign.AsyncClient; import feign.Client; import feign.Request; import feign.Request.Options; @@ -32,11 +33,22 @@ import java.net.http.HttpResponse; import java.net.http.HttpResponse.BodyHandlers; import java.time.Duration; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.OptionalLong; +import java.util.Set; +import java.util.TreeSet; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.stream.Collectors; -public class Http2Client implements Client { +public class Http2Client implements Client, AsyncClient { private final HttpClient client; @@ -54,9 +66,14 @@ public Http2Client(HttpClient client) { @Override public Response execute(Request request, Options options) throws IOException { - final HttpRequest httpRequest = newRequestBuilder(request, options).build(); - HttpClient clientForRequest = getOrCreateClient(options); + final HttpRequest httpRequest; + try { + httpRequest = newRequestBuilder(request, options).version(client.version()).build(); + } catch (URISyntaxException e) { + throw new IOException("Invalid uri " + request.url(), e); + } + HttpClient clientForRequest = getOrCreateClient(options); HttpResponse httpResponse; try { httpResponse = clientForRequest.send(httpRequest, BodyHandlers.ofByteArray()); @@ -65,19 +82,37 @@ public Response execute(Request request, Options options) throws IOException { throw new IOException("Invalid uri " + request.url(), e); } + return toFeignResponse(request, httpResponse); + } + + @Override + public CompletableFuture execute( + Request request, Options options, Optional requestContext) { + HttpRequest httpRequest; + try { + httpRequest = newRequestBuilder(request, options).build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid uri " + request.url(), e); + } + + HttpClient clientForRequest = getOrCreateClient(options); + CompletableFuture> future = + clientForRequest.sendAsync(httpRequest, HttpResponse.BodyHandlers.ofByteArray()); + return future.thenApply(httpResponse -> toFeignResponse(request, httpResponse)); + } + + protected Response toFeignResponse(Request request, HttpResponse httpResponse) { final OptionalLong length = httpResponse.headers().firstValueAsLong("Content-Length"); - final Response response = - Response.builder() - .body( - new ByteArrayInputStream(httpResponse.body()), - length.isPresent() ? (int) length.getAsLong() : null) - .reason(httpResponse.headers().firstValue("Reason-Phrase").orElse("OK")) - .request(request) - .status(httpResponse.statusCode()) - .headers(castMapCollectType(httpResponse.headers().map())) - .build(); - return response; + return Response.builder() + .body( + new ByteArrayInputStream(httpResponse.body()), + length.isPresent() ? (int) length.getAsLong() : null) + .reason(httpResponse.headers().firstValue("Reason-Phrase").orElse("OK")) + .request(request) + .status(httpResponse.statusCode()) + .headers(castMapCollectType(httpResponse.headers().map())) + .build(); } private HttpClient getOrCreateClient(Options options) { @@ -114,13 +149,8 @@ private static java.net.http.HttpClient.Builder newClientBuilder(Options options .connectTimeout(Duration.ofMillis(options.connectTimeoutMillis())); } - private Builder newRequestBuilder(Request request, Options options) throws IOException { - URI uri; - try { - uri = new URI(request.url()); - } catch (final URISyntaxException e) { - throw new IOException("Invalid uri " + request.url(), e); - } + private Builder newRequestBuilder(Request request, Options options) throws URISyntaxException { + URI uri = new URI(request.url()); final BodyPublisher body; final byte[] data = request.body(); @@ -134,7 +164,7 @@ private Builder newRequestBuilder(Request request, Options options) throws IOExc HttpRequest.newBuilder() .uri(uri) .timeout(Duration.ofMillis(options.readTimeoutMillis())) - .version(Version.HTTP_2); + .version(client.version()); final Map> headers = filterRestrictedHeaders(request.headers()); if (!headers.isEmpty()) { diff --git a/java11/src/test/java/feign/http2client/test/CustomPojo.java b/java11/src/test/java/feign/http2client/test/CustomPojo.java new file mode 100644 index 000000000..d24ed5cc1 --- /dev/null +++ b/java11/src/test/java/feign/http2client/test/CustomPojo.java @@ -0,0 +1,25 @@ +/** + * Copyright 2012-2020 The Feign Authors + * + *

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.http2client.test; + +public class CustomPojo { + + private final String name; + private final Integer number; + + CustomPojo(String name, Integer number) { + this.name = name; + this.number = number; + } +} diff --git a/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java new file mode 100644 index 000000000..78db6a2ca --- /dev/null +++ b/java11/src/test/java/feign/http2client/test/Http2ClientAsyncTest.java @@ -0,0 +1,1116 @@ +/** + * Copyright 2012-2021 The Feign Authors + * + *

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.http2client.test; + +import static feign.assertj.MockWebServerAssertions.assertThat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.data.MapEntry.entry; +import static org.hamcrest.CoreMatchers.isA; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.google.gson.Gson; +import com.google.gson.reflect.TypeToken; +import feign.AsyncClient; +import feign.AsyncFeign; +import feign.Body; +import feign.ChildPojo; +import feign.Feign; +import feign.Feign.ResponseMappingDecoder; +import feign.FeignException; +import feign.HeaderMap; +import feign.Headers; +import feign.Param; +import feign.PropertyPojo; +import feign.QueryMap; +import feign.QueryMapEncoder; +import feign.Request; +import feign.Request.HttpMethod; +import feign.RequestInterceptor; +import feign.RequestLine; +import feign.RequestTemplate; +import feign.Response; +import feign.ResponseMapper; +import feign.Target; +import feign.Target.HardCodedTarget; +import feign.Util; +import feign.codec.DecodeException; +import feign.codec.Decoder; +import feign.codec.EncodeException; +import feign.codec.Encoder; +import feign.codec.ErrorDecoder; +import feign.codec.StringDecoder; +import feign.http2client.Http2Client; +import feign.querymap.BeanQueryMapEncoder; +import feign.querymap.FieldQueryMapEncoder; +import java.io.IOException; +import java.lang.reflect.Type; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; +import org.assertj.core.api.Assertions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; + +public class Http2ClientAsyncTest { + @Rule public final ExpectedException thrown = ExpectedException.none(); + @Rule public final MockWebServer server = new MockWebServer(); + + @Test + public void iterableQueryParams() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + api.queryParams("user", Arrays.asList("apple", "pear")); + + assertThat(server.takeRequest()).hasPath("/?1=user&2=apple&2=pear"); + } + + @Test + public void postTemplateParamsResolve() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + api.login("netflix", "denominator", "password"); + + assertThat(server.takeRequest()) + .hasBody( + "{\"customer_name\": \"netflix\", \"user_name\": \"denominator\", \"password\":" + + " \"password\"}"); + } + + @Test + public void responseCoercesToStringBody() throws Throwable { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final Response response = unwrap(api.response()); + assertTrue(response.body().isRepeatable()); + assertEquals("foo", Util.toString(response.body().asReader(StandardCharsets.UTF_8))); + } + + @Test + public void postFormParams() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.form("netflix", "denominator", "password"); + + assertThat(server.takeRequest()) + .hasBody( + "{\"customer_name\":\"netflix\",\"user_name\":\"denominator\",\"password\":\"password\"}"); + + checkCFCompletedSoon(cf); + } + + @Test + public void postBodyParam() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.body(Arrays.asList("netflix", "denominator", "password")); + + assertThat(server.takeRequest()) + .hasHeaders(entry("Content-Length", Collections.singletonList("32"))) + .hasBody("[netflix, denominator, password]"); + + checkCFCompletedSoon(cf); + } + + /** + * The type of a parameter value may not be the desired type to encode as. Prefer the interface + * type. + */ + @Test + public void bodyTypeCorrespondsWithParameterType() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final AtomicReference encodedType = new AtomicReference(); + final TestInterfaceAsync api = + newAsyncBuilder() + .encoder( + new Encoder.Default() { + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + encodedType.set(bodyType); + } + }) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.body(Arrays.asList("netflix", "denominator", "password")); + + server.takeRequest(); + + Assertions.assertThat(encodedType.get()).isEqualTo(new TypeToken>() {}.getType()); + + checkCFCompletedSoon(cf); + } + + @Test + public void singleInterceptor() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + newAsyncBuilder() + .requestInterceptor(new ForwardedForInterceptor()) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.post(); + + assertThat(server.takeRequest()) + .hasHeaders(entry("X-Forwarded-For", Collections.singletonList("origin.host.com"))); + + checkCFCompletedSoon(cf); + } + + @Test + public void multipleInterceptor() throws Exception { + server.enqueue(new MockResponse().setBody("foo")); + + final TestInterfaceAsync api = + newAsyncBuilder() + .requestInterceptor(new ForwardedForInterceptor()) + .requestInterceptor(new UserAgentInterceptor()) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.post(); + + assertThat(server.takeRequest()) + .hasHeaders( + entry("X-Forwarded-For", Collections.singletonList("origin.host.com")), + entry("User-Agent", Collections.singletonList("Feign"))); + + checkCFCompletedSoon(cf); + } + + @Test + public void customExpander() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.expand(new Date(1234l)); + + assertThat(server.takeRequest()).hasPath("/?date=1234"); + + checkCFCompletedSoon(cf); + } + + @Test + public void customExpanderListParam() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = + api.expandList(Arrays.asList(new Date(1234l), new Date(12345l))); + + assertThat(server.takeRequest()).hasPath("/?date=1234&date=12345"); + + checkCFCompletedSoon(cf); + } + + @Test + public void customExpanderNullParam() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.expandList(Arrays.asList(new Date(1234l), null)); + + assertThat(server.takeRequest()).hasPath("/?date=1234"); + + checkCFCompletedSoon(cf); + } + + @Test + public void headerMap() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final Map headerMap = new LinkedHashMap(); + headerMap.put("Content-Type", "myContent"); + headerMap.put("Custom-Header", "fooValue"); + final CompletableFuture cf = api.headerMap(headerMap); + + assertThat(server.takeRequest()) + .hasHeaders( + entry("Content-Type", Arrays.asList("myContent")), + entry("Custom-Header", Arrays.asList("fooValue"))); + + checkCFCompletedSoon(cf); + } + + @Test + public void headerMapWithHeaderAnnotations() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final Map headerMap = new LinkedHashMap(); + headerMap.put("Custom-Header", "fooValue"); + api.headerMapWithHeaderAnnotations(headerMap); + + // header map should be additive for headers provided by annotations + assertThat(server.takeRequest()) + .hasHeaders( + entry("Content-Encoding", Arrays.asList("deflate")), + entry("Custom-Header", Arrays.asList("fooValue"))); + + server.enqueue(new MockResponse()); + headerMap.put("Content-Encoding", "overrideFromMap"); + + final CompletableFuture cf = api.headerMapWithHeaderAnnotations(headerMap); + + /* + * @HeaderMap map values no longer override @Header parameters. This caused confusion as it is + * valid to have more than one value for a header. + */ + assertThat(server.takeRequest()) + .hasHeaders( + entry("Content-Encoding", Arrays.asList("deflate", "overrideFromMap")), + entry("Custom-Header", Arrays.asList("fooValue"))); + + checkCFCompletedSoon(cf); + } + + @Test + public void queryMap() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final Map queryMap = new LinkedHashMap(); + queryMap.put("name", "alice"); + queryMap.put("fooKey", "fooValue"); + final CompletableFuture cf = api.queryMap(queryMap); + + assertThat(server.takeRequest()).hasPath("/?name=alice&fooKey=fooValue"); + + checkCFCompletedSoon(cf); + } + + @Test + public void queryMapIterableValuesExpanded() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final Map queryMap = new LinkedHashMap(); + queryMap.put("name", Arrays.asList("Alice", "Bob")); + queryMap.put("fooKey", "fooValue"); + queryMap.put("emptyListKey", new ArrayList()); + queryMap.put("emptyStringKey", ""); // empty values are ignored. + final CompletableFuture cf = api.queryMap(queryMap); + + assertThat(server.takeRequest()) + .hasPath("/?name=Alice&name=Bob&fooKey=fooValue&emptyStringKey"); + + checkCFCompletedSoon(cf); + } + + @Test + public void queryMapWithQueryParams() throws Exception { + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + server.enqueue(new MockResponse()); + Map queryMap = new LinkedHashMap(); + queryMap.put("fooKey", "fooValue"); + api.queryMapWithQueryParams("alice", queryMap); + // query map should be expanded after built-in parameters + assertThat(server.takeRequest()).hasPath("/?name=alice&fooKey=fooValue"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", "bob"); + api.queryMapWithQueryParams("alice", queryMap); + // queries are additive + assertThat(server.takeRequest()).hasPath("/?name=alice&name=bob"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", null); + api.queryMapWithQueryParams("alice", queryMap); + // null value for a query map key removes query parameter + assertThat(server.takeRequest()).hasPath("/?name=alice"); + } + + @Test + public void queryMapValueStartingWithBrace() throws Exception { + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + server.enqueue(new MockResponse()); + Map queryMap = new LinkedHashMap(); + queryMap.put("name", "{alice"); + api.queryMap(queryMap); + assertThat(server.takeRequest()).hasPath("/?name=%7Balice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("{name", "alice"); + api.queryMap(queryMap); + assertThat(server.takeRequest()).hasPath("/?%7Bname=alice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("name", "%7Balice"); + api.queryMapEncoded(queryMap); + assertThat(server.takeRequest()).hasPath("/?name=%7Balice"); + + server.enqueue(new MockResponse()); + queryMap = new LinkedHashMap(); + queryMap.put("%7Bname", "%7Balice"); + api.queryMapEncoded(queryMap); + assertThat(server.takeRequest()).hasPath("/?%7Bname=%7Balice"); + } + + @Test + public void queryMapPojoWithFullParams() throws Exception { + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CustomPojo customPojo = new CustomPojo("Name", 3); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPojo(customPojo); + assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("name=Name", "number=3")); + checkCFCompletedSoon(cf); + } + + @Test + public void queryMapPojoWithPartialParams() throws Exception { + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CustomPojo customPojo = new CustomPojo("Name", null); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPojo(customPojo); + assertThat(server.takeRequest()).hasPath("/?name=Name"); + + checkCFCompletedSoon(cf); + } + + @Test + public void queryMapPojoWithEmptyParams() throws Exception { + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CustomPojo customPojo = new CustomPojo(null, null); + + server.enqueue(new MockResponse()); + api.queryMapPojo(customPojo); + assertThat(server.takeRequest()).hasPath("/"); + } + + @Test + public void configKeyFormatsAsExpected() throws Exception { + assertEquals( + "TestInterfaceAsync#post()", + Feign.configKey( + TestInterfaceAsync.class, TestInterfaceAsync.class.getDeclaredMethod("post"))); + assertEquals( + "TestInterfaceAsync#uriParam(String,URI,String)", + Feign.configKey( + TestInterfaceAsync.class, + TestInterfaceAsync.class.getDeclaredMethod( + "uriParam", String.class, URI.class, String.class))); + } + + @Test + public void configKeyUsesChildType() throws Exception { + assertEquals( + "List#iterator()", + Feign.configKey(List.class, Iterable.class.getDeclaredMethod("iterator"))); + } + + private T unwrap(CompletableFuture cf) throws Throwable { + try { + return cf.get(1, TimeUnit.SECONDS); + } catch (final ExecutionException e) { + throw e.getCause(); + } + } + + @Test + public void canOverrideErrorDecoder() throws Throwable { + server.enqueue(new MockResponse().setResponseCode(400).setBody("foo")); + thrown.expect(IllegalArgumentException.class); + thrown.expectMessage("bad zone name"); + + final TestInterfaceAsync api = + newAsyncBuilder() + .errorDecoder(new IllegalArgumentExceptionOn400()) + .target("http://localhost:" + server.getPort()); + + unwrap(api.post()); + } + + @Test + public void overrideTypeSpecificDecoder() throws Throwable { + server.enqueue(new MockResponse().setBody("success!")); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decoder((response, type) -> "fail") + .target("http://localhost:" + server.getPort()); + + assertEquals("fail", unwrap(api.post())); + } + + @Test + public void doesntRetryAfterResponseIsSent() throws Throwable { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(FeignException.class); + thrown.expectMessage("timeout reading POST http://"); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decoder( + (response, type) -> { + throw new IOException("timeout"); + }) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.post(); + server.takeRequest(); + unwrap(cf); + } + + @Test + public void throwsFeignExceptionIncludingBody() throws Throwable { + server.enqueue(new MockResponse().setBody("success!")); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decoder( + (response, type) -> { + throw new IOException("timeout"); + }) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.body("Request body"); + server.takeRequest(); + try { + unwrap(cf); + } catch (final FeignException e) { + Assertions.assertThat(e.getMessage()) + .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/"); + Assertions.assertThat(e.contentUTF8()).isEqualTo("Request body"); + return; + } + fail(); + } + + @Test + public void throwsFeignExceptionWithoutBody() { + server.enqueue(new MockResponse().setBody("success!")); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decoder( + (response, type) -> { + throw new IOException("timeout"); + }) + .target("http://localhost:" + server.getPort()); + + try { + api.noContent(); + } catch (final FeignException e) { + Assertions.assertThat(e.getMessage()) + .isEqualTo("timeout reading POST http://localhost:" + server.getPort() + "/"); + Assertions.assertThat(e.contentUTF8()).isEqualTo(""); + } + } + + @SuppressWarnings("deprecation") + @Test + public void whenReturnTypeIsResponseNoErrorHandling() throws Throwable { + final Map> headers = new LinkedHashMap>(); + headers.put("Location", Arrays.asList("http://bar.com")); + final Response response = + Response.builder() + .status(302) + .reason("Found") + .headers(headers) + .request(Request.create(HttpMethod.GET, "/", Collections.emptyMap(), null, Util.UTF_8)) + .body(new byte[0]) + .build(); + + final ExecutorService execs = Executors.newSingleThreadExecutor(); + + // fake client as Client.Default follows redirects. + final TestInterfaceAsync api = + AsyncFeign.asyncBuilder() + .client(new AsyncClient.Default<>((request, options) -> response, execs)) + .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); + + assertThat(unwrap(api.response()).headers().get("Location")).contains("http://bar.com"); + + execs.shutdown(); + } + + @Test + public void okIfDecodeRootCauseHasNoMessage() throws Throwable { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(DecodeException.class); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decoder( + (response, type) -> { + throw new RuntimeException(); + }) + .target("http://localhost:" + server.getPort()); + + unwrap(api.post()); + } + + @Test + public void decodingExceptionGetWrappedInDecode404Mode() throws Throwable { + server.enqueue(new MockResponse().setResponseCode(404)); + thrown.expect(DecodeException.class); + thrown.expectCause(isA(NoSuchElementException.class)); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decode404() + .decoder( + (response, type) -> { + assertEquals(404, response.status()); + throw new NoSuchElementException(); + }) + .target("http://localhost:" + server.getPort()); + + unwrap(api.post()); + } + + @Test + public void decodingDoesNotSwallow404ErrorsInDecode404Mode() throws Throwable { + server.enqueue(new MockResponse().setResponseCode(404)); + thrown.expect(IllegalArgumentException.class); + + final TestInterfaceAsync api = + newAsyncBuilder() + .decode404() + .errorDecoder(new IllegalArgumentExceptionOn404()) + .target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.queryMap(Collections.emptyMap()); + server.takeRequest(); + unwrap(cf); + } + + @Test + public void okIfEncodeRootCauseHasNoMessage() throws Throwable { + server.enqueue(new MockResponse().setBody("success!")); + thrown.expect(EncodeException.class); + + final TestInterfaceAsync api = + newAsyncBuilder() + .encoder( + (object, bodyType, template) -> { + throw new RuntimeException(); + }) + .target("http://localhost:" + server.getPort()); + + unwrap(api.body(Arrays.asList("foo"))); + } + + @Test + public void equalsHashCodeAndToStringWork() { + final Target t1 = + new HardCodedTarget(TestInterfaceAsync.class, "http://localhost:8080"); + final Target t2 = + new HardCodedTarget(TestInterfaceAsync.class, "http://localhost:8888"); + final Target t3 = + new HardCodedTarget( + OtherTestInterfaceAsync.class, "http://localhost:8080"); + final TestInterfaceAsync i1 = newAsyncBuilder().target(t1); + final TestInterfaceAsync i2 = newAsyncBuilder().target(t1); + final TestInterfaceAsync i3 = newAsyncBuilder().target(t2); + final OtherTestInterfaceAsync i4 = newAsyncBuilder().target(t3); + + Assertions.assertThat(i1).isEqualTo(i2).isNotEqualTo(i3).isNotEqualTo(i4); + + Assertions.assertThat(i1.hashCode()) + .isEqualTo(i2.hashCode()) + .isNotEqualTo(i3.hashCode()) + .isNotEqualTo(i4.hashCode()); + + Assertions.assertThat(i1.toString()) + .isEqualTo(i2.toString()) + .isNotEqualTo(i3.toString()) + .isNotEqualTo(i4.toString()); + + Assertions.assertThat(t1).isNotEqualTo(i1); + + Assertions.assertThat(t1.hashCode()).isEqualTo(i1.hashCode()); + + Assertions.assertThat(t1.toString()).isEqualTo(i1.toString()); + } + + @SuppressWarnings("resource") + @Test + public void decodeLogicSupportsByteArray() throws Throwable { + final byte[] expectedResponse = {12, 34, 56}; + server.enqueue(new MockResponse().setBody(new Buffer().write(expectedResponse))); + + final OtherTestInterfaceAsync api = + newAsyncBuilder() + .target( + new HardCodedTarget<>( + OtherTestInterfaceAsync.class, "http://localhost:" + server.getPort())); + + Assertions.assertThat(unwrap(api.binaryResponseBody())).containsExactly(expectedResponse); + } + + @Test + public void encodeLogicSupportsByteArray() throws Exception { + final byte[] expectedRequest = {12, 34, 56}; + server.enqueue(new MockResponse()); + + final OtherTestInterfaceAsync api = + newAsyncBuilder() + .encoder(new Encoder.Default()) + .target( + new HardCodedTarget<>( + OtherTestInterfaceAsync.class, "http://localhost:" + server.getPort())); + + final CompletableFuture cf = api.binaryRequestBody(expectedRequest); + + assertThat(server.takeRequest()).hasBody(expectedRequest); + + checkCFCompletedSoon(cf); + } + + @Test + public void encodedQueryParam() throws Exception { + server.enqueue(new MockResponse()); + + final TestInterfaceAsync api = newAsyncBuilder().target("http://localhost:" + server.getPort()); + + final CompletableFuture cf = api.encodedQueryParam("5.2FSi+"); + + assertThat(server.takeRequest()).hasPath("/?trim=5.2FSi%2B"); + + checkCFCompletedSoon(cf); + } + + private void checkCFCompletedSoon(CompletableFuture cf) { + try { + unwrap(cf); + } catch (final RuntimeException e) { + throw e; + } catch (final Throwable t) { + throw new RuntimeException(t); + } + } + + @Test + public void responseMapperIsAppliedBeforeDelegate() throws IOException { + final ResponseMappingDecoder decoder = + new ResponseMappingDecoder(upperCaseResponseMapper(), new StringDecoder()); + final String output = (String) decoder.decode(responseWithText("response"), String.class); + + Assertions.assertThat(output).isEqualTo("RESPONSE"); + } + + private static TestInterfaceAsyncBuilder newAsyncBuilder() { + return new TestInterfaceAsyncBuilder(); + } + + private ResponseMapper upperCaseResponseMapper() { + return new ResponseMapper() { + @SuppressWarnings("deprecation") + @Override + public Response map(Response response, Type type) { + try { + return response.toBuilder() + .body(Util.toString(response.body().asReader()).toUpperCase().getBytes()) + .build(); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + }; + } + + @SuppressWarnings("deprecation") + private Response responseWithText(String text) { + return Response.builder() + .body(text, Util.UTF_8) + .status(200) + .request(Request.create(HttpMethod.GET, "/api", Collections.emptyMap(), null, Util.UTF_8)) + .headers(new HashMap<>()) + .build(); + } + + @Test + public void mapAndDecodeExecutesMapFunction() throws Throwable { + server.enqueue(new MockResponse().setBody("response!")); + + final TestInterfaceAsync api = + AsyncFeign.asyncBuilder() + .mapAndDecode(upperCaseResponseMapper(), new StringDecoder()) + .target(TestInterfaceAsync.class, "http://localhost:" + server.getPort()); + + assertEquals("RESPONSE!", unwrap(api.post())); + } + + @Test + public void beanQueryMapEncoderWithPrivateGetterIgnored() throws Exception { + final TestInterfaceAsync api = + newAsyncBuilder() + .queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + propertyPojo.setPrivateGetterProperty("privateGetterProperty"); + propertyPojo.setName("Name"); + propertyPojo.setNumber(1); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo); + assertThat(server.takeRequest()).hasQueryParams(Arrays.asList("name=Name", "number=1")); + checkCFCompletedSoon(cf); + } + + @Test + public void queryMap_with_child_pojo() throws Exception { + final TestInterfaceAsync api = + newAsyncBuilder() + .queryMapEndcoder(new FieldQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + final ChildPojo childPojo = new ChildPojo(); + childPojo.setChildPrivateProperty("first"); + childPojo.setParentProtectedProperty("second"); + childPojo.setParentPublicProperty("third"); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPropertyInheritence(childPojo); + assertThat(server.takeRequest()) + .hasQueryParams( + "parentPublicProperty=third", + "parentProtectedProperty=second", + "childPrivateProperty=first"); + checkCFCompletedSoon(cf); + } + + @Test + public void beanQueryMapEncoderWithNullValueIgnored() throws Exception { + final TestInterfaceAsync api = + newAsyncBuilder() + .queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + propertyPojo.setName(null); + propertyPojo.setNumber(1); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo); + + assertThat(server.takeRequest()).hasQueryParams("number=1"); + + checkCFCompletedSoon(cf); + } + + @Test + public void beanQueryMapEncoderWithEmptyParams() throws Exception { + final TestInterfaceAsync api = + newAsyncBuilder() + .queryMapEndcoder(new BeanQueryMapEncoder()) + .target("http://localhost:" + server.getPort()); + + final PropertyPojo.ChildPojoClass propertyPojo = new PropertyPojo.ChildPojoClass(); + + server.enqueue(new MockResponse()); + final CompletableFuture cf = api.queryMapPropertyPojo(propertyPojo); + assertThat(server.takeRequest()).hasQueryParams("/"); + + checkCFCompletedSoon(cf); + } + + public interface TestInterfaceAsync { + + @RequestLine("POST /") + CompletableFuture response(); + + @RequestLine("POST /") + CompletableFuture post() throws TestInterfaceException; + + @RequestLine("POST /") + @Body( + "%7B\"customer_name\": \"{customer_name}\", \"user_name\": \"{user_name}\", \"password\":" + + " \"{password}\"%7D") + CompletableFuture login( + @Param("customer_name") String customer, + @Param("user_name") String user, + @Param("password") String password); + + @RequestLine("POST /") + CompletableFuture body(List contents); + + @RequestLine("POST /") + CompletableFuture body(String content); + + @RequestLine("POST /") + CompletableFuture noContent(); + + @RequestLine("POST /") + @Headers("Content-Encoding: gzip") + CompletableFuture gzipBody(List contents); + + @RequestLine("POST /") + @Headers("Content-Encoding: deflate") + CompletableFuture deflateBody(List contents); + + @RequestLine("POST /") + CompletableFuture form( + @Param("customer_name") String customer, + @Param("user_name") String user, + @Param("password") String password); + + @RequestLine("GET /{1}/{2}") + CompletableFuture uriParam( + @Param("1") String one, URI endpoint, @Param("2") String two); + + @RequestLine("GET /?1={1}&2={2}") + CompletableFuture queryParams( + @Param("1") String one, @Param("2") Iterable twos); + + @RequestLine("POST /?date={date}") + CompletableFuture expand(@Param(value = "date", expander = DateToMillis.class) Date date); + + @RequestLine("GET /?date={date}") + CompletableFuture expandList( + @Param(value = "date", expander = DateToMillis.class) List dates); + + @RequestLine("GET /?date={date}") + CompletableFuture expandArray( + @Param(value = "date", expander = DateToMillis.class) Date[] dates); + + @RequestLine("GET /") + CompletableFuture headerMap(@HeaderMap Map headerMap); + + @RequestLine("GET /") + @Headers("Content-Encoding: deflate") + CompletableFuture headerMapWithHeaderAnnotations( + @HeaderMap Map headerMap); + + @RequestLine("GET /") + CompletableFuture queryMap(@QueryMap Map queryMap); + + @RequestLine("GET /") + CompletableFuture queryMapEncoded(@QueryMap(encoded = true) Map queryMap); + + @RequestLine("GET /?name={name}") + CompletableFuture queryMapWithQueryParams( + @Param("name") String name, @QueryMap Map queryMap); + + @RequestLine("GET /?trim={trim}") + CompletableFuture encodedQueryParam(@Param(value = "trim", encoded = true) String trim); + + @RequestLine("GET /") + CompletableFuture queryMapPojo(@QueryMap CustomPojo object); + + @RequestLine("GET /") + CompletableFuture queryMapPropertyPojo(@QueryMap PropertyPojo object); + + @RequestLine("GET /") + CompletableFuture queryMapPropertyInheritence(@QueryMap ChildPojo object); + + class DateToMillis implements Param.Expander { + + @Override + public String expand(Object value) { + return String.valueOf(((Date) value).getTime()); + } + } + } + + class TestInterfaceException extends Exception { + private static final long serialVersionUID = 1L; + + TestInterfaceException(String message) { + super(message); + } + } + + public interface OtherTestInterfaceAsync { + + @RequestLine("POST /") + CompletableFuture post(); + + @RequestLine("POST /") + CompletableFuture binaryResponseBody(); + + @RequestLine("POST /") + CompletableFuture binaryRequestBody(byte[] contents); + } + + static class ForwardedForInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate template) { + template.header("X-Forwarded-For", "origin.host.com"); + } + } + + static class UserAgentInterceptor implements RequestInterceptor { + + @Override + public void apply(RequestTemplate template) { + template.header("User-Agent", "Feign"); + } + } + + static class IllegalArgumentExceptionOn400 extends ErrorDecoder.Default { + + @Override + public Exception decode(String methodKey, Response response) { + if (response.status() == 400) { + return new IllegalArgumentException("bad zone name"); + } + return super.decode(methodKey, response); + } + } + + static class IllegalArgumentExceptionOn404 extends ErrorDecoder.Default { + + @Override + public Exception decode(String methodKey, Response response) { + if (response.status() == 404) { + return new IllegalArgumentException("bad zone name"); + } + return super.decode(methodKey, response); + } + } + + static final class TestInterfaceAsyncBuilder { + + private final AsyncFeign.AsyncBuilder delegate = + AsyncFeign.asyncBuilder() + .client(new Http2Client()) + .decoder(new Decoder.Default()) + .encoder( + new Encoder() { + + @SuppressWarnings("deprecation") + @Override + public void encode(Object object, Type bodyType, RequestTemplate template) { + if (object instanceof Map) { + template.body(new Gson().toJson(object)); + } else { + template.body(object.toString()); + } + } + }); + + TestInterfaceAsyncBuilder requestInterceptor(RequestInterceptor requestInterceptor) { + delegate.requestInterceptor(requestInterceptor); + return this; + } + + TestInterfaceAsyncBuilder encoder(Encoder encoder) { + delegate.encoder(encoder); + return this; + } + + TestInterfaceAsyncBuilder decoder(Decoder decoder) { + delegate.decoder(decoder); + return this; + } + + TestInterfaceAsyncBuilder errorDecoder(ErrorDecoder errorDecoder) { + delegate.errorDecoder(errorDecoder); + return this; + } + + TestInterfaceAsyncBuilder decode404() { + delegate.decode404(); + return this; + } + + TestInterfaceAsyncBuilder queryMapEndcoder(QueryMapEncoder queryMapEncoder) { + delegate.queryMapEncoder(queryMapEncoder); + return this; + } + + TestInterfaceAsync target(String url) { + return delegate.target(TestInterfaceAsync.class, url); + } + + T target(Target target) { + return delegate.target(target); + } + } + + static final class ExtendedCF extends CompletableFuture {} + + abstract static class NonInterface { + @RequestLine("GET /") + abstract CompletableFuture x(); + } + + interface NonCFApi { + @RequestLine("GET /") + void x(); + } + + interface ExtendedCFApi { + @RequestLine("GET /") + ExtendedCF x(); + } + + interface LowerWildApi { + @RequestLine("GET /") + CompletableFuture x(); + } + + interface UpperWildApi { + @RequestLine("GET /") + CompletableFuture x(); + } + + interface WildApi { + @RequestLine("GET /") + CompletableFuture x(); + } +}