diff --git a/extras/pom.xml b/extras/pom.xml index fe8d413f16..63af14a835 100644 --- a/extras/pom.xml +++ b/extras/pom.xml @@ -19,6 +19,7 @@ rxjava rxjava2 simple + retrofit2 diff --git a/extras/retrofit2/README.md b/extras/retrofit2/README.md new file mode 100644 index 0000000000..4edfe7166e --- /dev/null +++ b/extras/retrofit2/README.md @@ -0,0 +1,57 @@ +# Async-http-client Retrofit2 Call Adapter + +An `okhttp3.Call.Factory` for implementing async-http-client powered [Retrofit][1] type-safe HTTP clients. + +## Download + +Download [the latest JAR][2] or grab via [Maven][3]: + +```xml + + org.asynchttpclient + async-http-client-extras-retrofit2 + latest.version + +``` + +or [Gradle][3]: + +```groovy +compile "org.asynchttpclient:async-http-client-extras-retrofit2:latest.version" +``` + + [1]: http://square.github.io/retrofit/ + [2]: https://search.maven.org/remote_content?g=org.asynchttpclient&a=async-http-client-extras-retrofit2&v=LATEST + [3]: http://search.maven.org/#search%7Cga%7C1%7Cg%3A%22org.asynchttpclient%22%20a%3A%22async-http-client-extras-retrofit2%22 + [snap]: https://oss.sonatype.org/content/repositories/snapshots/ + +## Example usage + +```java +// instantiate async-http-client +AsyncHttpClient httpClient = ... + +// instantiate async-http-client call factory +Call.Factory callFactory = AsyncHttpClientCallFactory.builder() + .httpClient(httpClient) // required + .onRequestStart(onRequestStart) // optional + .onRequestFailure(onRequestFailure) // optional + .onRequestSuccess(onRequestSuccess) // optional + .requestCustomizer(requestCustomizer) // optional + .build(); + +// instantiate retrofit +Retrofit retrofit = new Retrofit.Builder() + .callFactory(callFactory) // use our own call factory + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(JacksonConverterFactory.create()) + // ... add other converter factories + // .addCallAdapterFactory(RxJavaCallAdapterFactory.createAsync()) + .validateEagerly(true) // highly recommended!!! + .baseUrl("https://api.github.com/"); + +// time to instantiate service +GitHub github = retrofit.create(Github.class); + +// enjoy your type-safe github service api! :-) +``` \ No newline at end of file diff --git a/extras/retrofit2/pom.xml b/extras/retrofit2/pom.xml new file mode 100644 index 0000000000..10eca28219 --- /dev/null +++ b/extras/retrofit2/pom.xml @@ -0,0 +1,63 @@ + + 4.0.0 + + + async-http-client-extras-parent + org.asynchttpclient + 2.1.0-SNAPSHOT + + + async-http-client-extras-retrofit2 + Asynchronous Http Client Retrofit2 Extras + The Async Http Client Retrofit2 Extras. + + + 2.3.0 + 1.16.16 + + + + + org.projectlombok + lombok + ${lombok.version} + provided + + + + com.squareup.retrofit2 + retrofit + ${retrofit2.version} + + + + + com.squareup.retrofit2 + converter-scalars + ${retrofit2.version} + test + + + + com.squareup.retrofit2 + converter-jackson + ${retrofit2.version} + test + + + + com.squareup.retrofit2 + adapter-rxjava + ${retrofit2.version} + test + + + + com.squareup.retrofit2 + adapter-rxjava2 + ${retrofit2.version} + test + + + diff --git a/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCall.java b/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCall.java new file mode 100644 index 0000000000..00cb272b16 --- /dev/null +++ b/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCall.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NonNull; +import lombok.Singular; +import lombok.SneakyThrows; +import lombok.Value; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import okhttp3.Call; +import okhttp3.Callback; +import okhttp3.MediaType; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import okio.Buffer; +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.RequestBuilder; + +import java.io.IOException; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +/** + * {@link AsyncHttpClient} Retrofit2 {@link okhttp3.Call} + * implementation. + */ +@Value +@Builder(toBuilder = true) +@Slf4j +class AsyncHttpClientCall implements Cloneable, okhttp3.Call { + /** + * Default {@link #execute()} timeout in milliseconds (value: {@value}) + * + * @see #execute() + * @see #executeTimeoutMillis + */ + public static final long DEFAULT_EXECUTE_TIMEOUT_MILLIS = 30_000; + + /** + * HttpClient instance. + */ + @NonNull + AsyncHttpClient httpClient; + + /** + * {@link #execute()} response timeout in milliseconds. + */ + @Builder.Default + long executeTimeoutMillis = DEFAULT_EXECUTE_TIMEOUT_MILLIS; + + /** + * Retrofit request. + */ + @NonNull + @Getter(AccessLevel.NONE) + Request request; + + /** + * List of consumers that get called just before actual async-http-client request is being built. + */ + @Singular("requestCustomizer") + List> requestCustomizers; + + /** + * List of consumers that get called just before actual HTTP request is being fired. + */ + @Singular("onRequestStart") + List> onRequestStart; + + /** + * List of consumers that get called when HTTP request finishes with an exception. + */ + @Singular("onRequestFailure") + List> onRequestFailure; + + /** + * List of consumers that get called when HTTP request finishes successfully. + */ + @Singular("onRequestSuccess") + List> onRequestSuccess; + + /** + * Tells whether call has been executed. + * + * @see #isExecuted() + * @see #isCanceled() + */ + private final AtomicReference> futureRef = new AtomicReference<>(); + + @Override + public Request request() { + return request; + } + + @Override + public Response execute() throws IOException { + try { + return executeHttpRequest().get(getExecuteTimeoutMillis(), TimeUnit.MILLISECONDS); + } catch (ExecutionException e) { + throw toIOException(e.getCause()); + } catch (Exception e) { + throw toIOException(e); + } + } + + @Override + public void enqueue(Callback responseCallback) { + executeHttpRequest() + .thenApply(response -> handleResponse(response, responseCallback)) + .exceptionally(throwable -> handleException(throwable, responseCallback)); + } + + @Override + public void cancel() { + val future = futureRef.get(); + if (future != null) { + if (!future.cancel(true)) { + log.warn("Cannot cancel future: {}", future); + } + } + } + + @Override + public boolean isExecuted() { + val future = futureRef.get(); + return future != null && future.isDone(); + } + + @Override + public boolean isCanceled() { + val future = futureRef.get(); + return future != null && future.isCancelled(); + } + + @Override + public Call clone() { + return toBuilder().build(); + } + + protected T handleException(Throwable throwable, Callback responseCallback) { + try { + if (responseCallback == null) { + responseCallback.onFailure(this, toIOException(throwable)); + } + } catch (Exception e) { + log.error("Exception while executing onFailure() on {}: {}", responseCallback, e.getMessage(), e); + } + return null; + } + + protected Response handleResponse(Response response, Callback responseCallback) { + try { + if (responseCallback != null) { + responseCallback.onResponse(this, response); + } + } catch (Exception e) { + log.error("Exception while executing onResponse() on {}: {}", responseCallback, e.getMessage(), e); + } + return response; + } + + protected CompletableFuture executeHttpRequest() { + if (futureRef.get() != null) { + throwAlreadyExecuted(); + } + + // create future and try to store it into atomic reference + val future = new CompletableFuture(); + if (!futureRef.compareAndSet(null, future)) { + throwAlreadyExecuted(); + } + + // create request + val asyncHttpClientRequest = createRequest(request()); + + // execute the request. + val me = this; + runConsumers(this.onRequestStart, this.request); + getHttpClient().executeRequest(asyncHttpClientRequest, new AsyncCompletionHandler() { + @Override + public void onThrowable(Throwable t) { + runConsumers(me.onRequestFailure, t); + future.completeExceptionally(t); + } + + @Override + public Response onCompleted(org.asynchttpclient.Response response) throws Exception { + val okHttpResponse = toOkhttpResponse(response); + runConsumers(me.onRequestSuccess, okHttpResponse); + future.complete(okHttpResponse); + return okHttpResponse; + } + }); + + return future; + } + + /** + * Converts async-http-client response to okhttp response. + * + * @param asyncHttpClientResponse async-http-client response + * @return okhttp response. + * @throws NullPointerException in case of null arguments + */ + private Response toOkhttpResponse(org.asynchttpclient.Response asyncHttpClientResponse) { + // status code + val rspBuilder = new Response.Builder() + .request(request()) + .protocol(Protocol.HTTP_1_1) + .code(asyncHttpClientResponse.getStatusCode()) + .message(asyncHttpClientResponse.getStatusText()); + + // headers + if (asyncHttpClientResponse.hasResponseHeaders()) { + asyncHttpClientResponse.getHeaders().forEach(e -> rspBuilder.header(e.getKey(), e.getValue())); + } + + // body + if (asyncHttpClientResponse.hasResponseBody()) { + val contentType = MediaType.parse(asyncHttpClientResponse.getContentType()); + val okHttpBody = ResponseBody.create(contentType, asyncHttpClientResponse.getResponseBodyAsBytes()); + rspBuilder.body(okHttpBody); + } + + return rspBuilder.build(); + } + + protected IOException toIOException(@NonNull Throwable exception) { + if (exception instanceof IOException) { + return (IOException) exception; + } else { + val message = (exception.getMessage() == null) ? exception.toString() : exception.getMessage(); + return new IOException(message, exception); + } + } + + /** + * Converts retrofit request to async-http-client request. + * + * @param request retrofit request + * @return async-http-client request. + */ + @SneakyThrows + protected org.asynchttpclient.Request createRequest(@NonNull Request request) { + // create async-http-client request builder + val requestBuilder = new RequestBuilder(request.method()); + + // request uri + requestBuilder.setUrl(request.url().toString()); + + // set headers + val headers = request.headers(); + headers.names().forEach(name -> requestBuilder.setHeader(name, headers.values(name))); + + // set request body + val body = request.body(); + if (body != null && body.contentLength() > 0) { + // write body to buffer + val okioBuffer = new Buffer(); + body.writeTo(okioBuffer); + requestBuilder.setBody(okioBuffer.readByteArray()); + } + + // customize the request builder (external customizer can change the request url for example) + runConsumers(this.requestCustomizers, requestBuilder); + + return requestBuilder.build(); + } + + /** + * Safely runs specified consumer. + * + * @param consumer consumer (may be null) + * @param argument consumer argument + * @param consumer type. + */ + protected static void runConsumer(Consumer consumer, T argument) { + try { + if (consumer != null) { + consumer.accept(argument); + } + } catch (Exception e) { + log.error("Exception while running consumer {}: {}", consumer, e.getMessage(), e); + } + } + + /** + * Safely runs multiple consumers. + * + * @param consumers collection of consumers (may be null) + * @param argument consumer argument + * @param consumer type. + */ + protected static void runConsumers(Collection> consumers, T argument) { + if (consumers == null || consumers.isEmpty()) { + return; + } + consumers.forEach(consumer -> runConsumer(consumer, argument)); + } + + private void throwAlreadyExecuted() { + throw new IllegalStateException("This call has already been executed."); + } +} diff --git a/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactory.java b/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactory.java new file mode 100644 index 0000000000..0376628b7e --- /dev/null +++ b/extras/retrofit2/src/main/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactory.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import lombok.Builder; +import lombok.NonNull; +import lombok.Singular; +import lombok.Value; +import lombok.val; +import okhttp3.Call; +import okhttp3.Request; +import org.asynchttpclient.AsyncHttpClient; + +import java.util.List; +import java.util.function.Consumer; + +import static org.asynchttpclient.extras.retrofit.AsyncHttpClientCall.runConsumers; + +/** + * {@link AsyncHttpClient} implementation of Retrofit2 {@link Call.Factory} + */ +@Value +@Builder(toBuilder = true) +public class AsyncHttpClientCallFactory implements Call.Factory { + /** + * {@link AsyncHttpClient} in use. + */ + @NonNull + AsyncHttpClient httpClient; + + /** + * List of {@link Call} builder customizers that are invoked just before creating it. + */ + @Singular("callCustomizer") + List> callCustomizers; + + @Override + public Call newCall(Request request) { + val callBuilder = AsyncHttpClientCall.builder() + .httpClient(httpClient) + .request(request); + + // customize builder before creating a call + runConsumers(this.callCustomizers, callBuilder); + + // create a call + return callBuilder.build(); + } +} diff --git a/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactoryTest.java b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactoryTest.java new file mode 100644 index 0000000000..58eef1c91c --- /dev/null +++ b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallFactoryTest.java @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import okhttp3.Request; +import okhttp3.Response; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.RequestBuilder; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.asynchttpclient.extras.retrofit.AsyncHttpClientCallTest.REQUEST; +import static org.asynchttpclient.extras.retrofit.AsyncHttpClientCallTest.createConsumer; +import static org.mockito.Mockito.mock; +import static org.testng.Assert.*; + +@Slf4j +public class AsyncHttpClientCallFactoryTest { + @Test + void newCallShouldProduceExpectedResult() { + // given + val request = new Request.Builder().url("http://www.google.com/").build(); + val httpClient = mock(AsyncHttpClient.class); + + Consumer onRequestStart = createConsumer(new AtomicInteger()); + Consumer onRequestFailure = createConsumer(new AtomicInteger()); + Consumer onRequestSuccess = createConsumer(new AtomicInteger()); + Consumer requestCustomizer = createConsumer(new AtomicInteger()); + + // first call customizer + val customizer1Called = new AtomicInteger(); + Consumer callBuilderConsumer1 = builder -> { + builder.onRequestStart(onRequestStart) + .onRequestFailure(onRequestFailure) + .onRequestSuccess(onRequestSuccess); + customizer1Called.incrementAndGet(); + }; + + // first call customizer + val customizer2Called = new AtomicInteger(); + Consumer callBuilderConsumer2 = builder -> { + builder.requestCustomizer(requestCustomizer); + customizer2Called.incrementAndGet(); + }; + + // when: create call factory + val factory = AsyncHttpClientCallFactory.builder() + .httpClient(httpClient) + .callCustomizer(callBuilderConsumer1) + .callCustomizer(callBuilderConsumer2) + .build(); + + // then + assertTrue(factory.getHttpClient() == httpClient); + assertTrue(factory.getCallCustomizers().size() == 2); + assertTrue(customizer1Called.get() == 0); + assertTrue(customizer2Called.get() == 0); + + // when + val call = (AsyncHttpClientCall) factory.newCall(request); + + // then + assertNotNull(call); + assertTrue(customizer1Called.get() == 1); + assertTrue(customizer2Called.get() == 1); + + assertTrue(call.request() == request); + assertTrue(call.getHttpClient() == httpClient); + + assertEquals(call.getOnRequestStart().get(0), onRequestStart); + assertEquals(call.getOnRequestFailure().get(0), onRequestFailure); + assertEquals(call.getOnRequestSuccess().get(0), onRequestSuccess); + assertEquals(call.getRequestCustomizers().get(0), requestCustomizer); + } + + @Test + void shouldApplyAllConsumersToCallBeingConstructed() throws IOException { + // given + val httpClient = mock(AsyncHttpClient.class); + + val rewriteUrl = "http://foo.bar.com/"; + val headerName = "X-Header"; + val headerValue = UUID.randomUUID().toString(); + + val numCustomized = new AtomicInteger(); + val numRequestStart = new AtomicInteger(); + val numRequestSuccess = new AtomicInteger(); + val numRequestFailure = new AtomicInteger(); + + Consumer requestCustomizer = requestBuilder -> { + requestBuilder.setUrl(rewriteUrl) + .setHeader(headerName, headerValue); + numCustomized.incrementAndGet(); + }; + + Consumer callCustomizer = callBuilder -> { + callBuilder + .requestCustomizer(requestCustomizer) + .requestCustomizer(rb -> log.warn("I'm customizing: {}", rb)) + .onRequestSuccess(createConsumer(numRequestSuccess)) + .onRequestFailure(createConsumer(numRequestFailure)) + .onRequestStart(createConsumer(numRequestStart)); + }; + + // create factory + val factory = AsyncHttpClientCallFactory.builder() + .callCustomizer(callCustomizer) + .httpClient(httpClient) + .build(); + + // when + val call = (AsyncHttpClientCall) factory.newCall(REQUEST); + val callRequest = call.createRequest(call.request()); + + // then + assertTrue(numCustomized.get() == 1); + assertTrue(numRequestStart.get() == 0); + assertTrue(numRequestSuccess.get() == 0); + assertTrue(numRequestFailure.get() == 0); + + // let's see whether request customizers did their job + // final async-http-client request should have modified URL and one + // additional header value. + assertEquals(callRequest.getUrl(), rewriteUrl); + assertEquals(callRequest.getHeaders().get(headerName), headerValue); + + // final call should have additional consumers set + assertNotNull(call.getOnRequestStart()); + assertTrue(call.getOnRequestStart().size() == 1); + + assertNotNull(call.getOnRequestSuccess()); + assertTrue(call.getOnRequestSuccess().size() == 1); + + assertNotNull(call.getOnRequestFailure()); + assertTrue(call.getOnRequestFailure().size() == 1); + + assertNotNull(call.getRequestCustomizers()); + assertTrue(call.getRequestCustomizers().size() == 2); + } +} diff --git a/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallTest.java b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallTest.java new file mode 100644 index 0000000000..2f04e1947d --- /dev/null +++ b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpClientCallTest.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import io.netty.handler.codec.http.EmptyHttpHeaders; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import okhttp3.Request; +import org.asynchttpclient.AsyncCompletionHandler; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.BoundRequestBuilder; +import org.asynchttpclient.Response; +import org.testng.Assert; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collection; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.asynchttpclient.extras.retrofit.AsyncHttpClientCall.runConsumer; +import static org.asynchttpclient.extras.retrofit.AsyncHttpClientCall.runConsumers; +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertTrue; + +@Slf4j +public class AsyncHttpClientCallTest { + static final Request REQUEST = new Request.Builder().url("http://www.google.com/").build(); + + @Test(expectedExceptions = NullPointerException.class, dataProvider = "first") + void builderShouldThrowInCaseOfMissingProperties(AsyncHttpClientCall.AsyncHttpClientCallBuilder builder) { + builder.build(); + } + + @DataProvider(name = "first") + Object[][] dataProviderFirst() { + val httpClient = mock(AsyncHttpClient.class); + + return new Object[][]{ + {AsyncHttpClientCall.builder()}, + {AsyncHttpClientCall.builder().request(REQUEST)}, + {AsyncHttpClientCall.builder().httpClient(httpClient)} + }; + } + + @Test(dataProvider = "second") + void shouldInvokeConsumersOnEachExecution(Consumer handlerConsumer, + int expectedStarted, + int expectedOk, + int expectedFailed) { + // given + + // counters + val numStarted = new AtomicInteger(); + val numOk = new AtomicInteger(); + val numFailed = new AtomicInteger(); + val numRequestCustomizer = new AtomicInteger(); + + // prepare http client mock + val httpClient = mock(AsyncHttpClient.class); + + val mockRequest = mock(org.asynchttpclient.Request.class); + when(mockRequest.getHeaders()).thenReturn(EmptyHttpHeaders.INSTANCE); + + val brb = new BoundRequestBuilder(httpClient, mockRequest); + when(httpClient.prepareRequest((org.asynchttpclient.RequestBuilder) any())).thenReturn(brb); + + when(httpClient.executeRequest((org.asynchttpclient.Request) any(), any())).then(invocationOnMock -> { + val handler = invocationOnMock.getArgumentAt(1, AsyncCompletionHandler.class); + handlerConsumer.accept(handler); + return null; + }); + + // create call instance + val call = AsyncHttpClientCall.builder() + .httpClient(httpClient) + .request(REQUEST) + .onRequestStart(e -> numStarted.incrementAndGet()) + .onRequestFailure(t -> numFailed.incrementAndGet()) + .onRequestSuccess(r -> numOk.incrementAndGet()) + .requestCustomizer(rb -> numRequestCustomizer.incrementAndGet()) + .executeTimeoutMillis(1000) + .build(); + + // when + Assert.assertFalse(call.isExecuted()); + Assert.assertFalse(call.isCanceled()); + try { + call.execute(); + } catch (Exception e) { + } + + // then + assertTrue(call.isExecuted()); + Assert.assertFalse(call.isCanceled()); + assertTrue(numRequestCustomizer.get() == 1); // request customizer must be always invoked. + assertTrue(numStarted.get() == expectedStarted); + assertTrue(numOk.get() == expectedOk); + assertTrue(numFailed.get() == expectedFailed); + + // try with non-blocking call + numStarted.set(0); + numOk.set(0); + numFailed.set(0); + val clonedCall = call.clone(); + + // when + clonedCall.enqueue(null); + + // then + assertTrue(clonedCall.isExecuted()); + Assert.assertFalse(clonedCall.isCanceled()); + assertTrue(numRequestCustomizer.get() == 2); // request customizer must be always invoked. + assertTrue(numStarted.get() == expectedStarted); + assertTrue(numOk.get() == expectedOk); + assertTrue(numFailed.get() == expectedFailed); + } + + @DataProvider(name = "second") + Object[][] dataProviderSecond() { + // mock response + val response = mock(Response.class); + when(response.getStatusCode()).thenReturn(200); + when(response.getStatusText()).thenReturn("OK"); + when(response.getHeaders()).thenReturn(EmptyHttpHeaders.INSTANCE); + + AsyncCompletionHandler x = null; + + Consumer okConsumer = handler -> { + try { + handler.onCompleted(response); + } catch (Exception e) { + } + }; + Consumer failedConsumer = handler -> handler.onThrowable(new TimeoutException("foo")); + + return new Object[][]{ + {okConsumer, 1, 1, 0}, + {failedConsumer, 1, 0, 1} + }; + } + + @Test(dataProvider = "third") + void toIOExceptionShouldProduceExpectedResult(Throwable exception) { + // given + val call = AsyncHttpClientCall.builder() + .httpClient(mock(AsyncHttpClient.class)) + .request(REQUEST) + .build(); + + // when + val result = call.toIOException(exception); + + // then + Assert.assertNotNull(result); + assertTrue(result instanceof IOException); + + if (exception.getMessage() == null) { + assertTrue(result.getMessage() == exception.toString()); + } else { + assertTrue(result.getMessage() == exception.getMessage()); + } + } + + @DataProvider(name = "third") + Object[][] dataProviderThird() { + return new Object[][]{ + {new IOException("foo")}, + {new RuntimeException("foo")}, + {new IllegalArgumentException("foo")}, + {new ExecutionException(new RuntimeException("foo"))}, + }; + } + + @Test(dataProvider = "4th") + void runConsumerShouldTolerateBadConsumers(Consumer consumer, T argument) { + // when + runConsumer(consumer, argument); + + // then + assertTrue(true); + } + + @DataProvider(name = "4th") + Object[][] dataProvider4th() { + return new Object[][]{ + {null, null}, + {(Consumer) s -> s.trim(), null}, + {null, "foobar"}, + {(Consumer) s -> doThrow("trololo"), null}, + {(Consumer) s -> doThrow("trololo"), "foo"}, + }; + } + + @Test(dataProvider = "5th") + void runConsumersShouldTolerateBadConsumers(Collection> consumers, T argument) { + // when + runConsumers(consumers, argument); + + // then + assertTrue(true); + } + + @DataProvider(name = "5th") + Object[][] dataProvider5th() { + return new Object[][]{ + {null, null}, + {Arrays.asList((Consumer) s -> s.trim()), null}, + {Arrays.asList(s -> s.trim(), null, (Consumer) s -> s.isEmpty()), null}, + {null, "foobar"}, + {Arrays.asList((Consumer) s -> doThrow("trololo")), null}, + {Arrays.asList((Consumer) s -> doThrow("trololo")), "foo"}, + }; + } + + private void doThrow(String message) { + throw new RuntimeException(message); + } + + /** + * Creates consumer that increments counter when it's called. + * + * @param counter counter that is going to be called + * @param consumer type + * @return consumer. + */ + protected static Consumer createConsumer(AtomicInteger counter) { + return e -> counter.incrementAndGet(); + } +} diff --git a/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpRetrofitIntegrationTest.java b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpRetrofitIntegrationTest.java new file mode 100644 index 0000000000..d370e38c9a --- /dev/null +++ b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/AsyncHttpRetrofitIntegrationTest.java @@ -0,0 +1,443 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.asynchttpclient.AsyncHttpClient; +import org.asynchttpclient.DefaultAsyncHttpClient; +import org.asynchttpclient.DefaultAsyncHttpClientConfig; +import org.asynchttpclient.testserver.HttpServer; +import org.asynchttpclient.testserver.HttpTest; +import org.testng.annotations.AfterSuite; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeTest; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; +import retrofit2.HttpException; +import retrofit2.Retrofit; +import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory; +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory; +import retrofit2.converter.jackson.JacksonConverterFactory; +import retrofit2.converter.scalars.ScalarsConverterFactory; +import rx.schedulers.Schedulers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.IntStream; + +import static org.asynchttpclient.extras.retrofit.TestServices.Contributor; +import static org.testng.Assert.*; +import static org.testng.AssertJUnit.assertEquals; + +/** + * All tests in this test suite are disabled, because they call functionality of github service that is + * rate-limited. + */ +@Slf4j +public class AsyncHttpRetrofitIntegrationTest extends HttpTest { + private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final String OWNER = "AsyncHttpClient"; + private static final String REPO = "async-http-client"; + + private static final AsyncHttpClient httpClient = createHttpClient(); + private static HttpServer server; + + private List expectedContributors; + + private static AsyncHttpClient createHttpClient() { + val config = new DefaultAsyncHttpClientConfig.Builder() + .setCompressionEnforced(true) + .setTcpNoDelay(true) + .setKeepAlive(true) + .setPooledConnectionIdleTimeout(120_000) + .setFollowRedirect(true) + .setMaxRedirects(5) + .build(); + + return new DefaultAsyncHttpClient(config); + } + + @BeforeClass + public static void start() throws Throwable { + server = new HttpServer(); + server.start(); + } + + @BeforeTest + void before() { + this.expectedContributors = generateContributors(); + } + + @AfterSuite + void cleanup() throws IOException { + httpClient.close(); + } + + // begin: synchronous execution + @Test + public void testSynchronousService_OK() throws Throwable { + // given + val service = synchronousSetup(); + + // when: + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "utf-8"); + + val contributors = service.contributors(OWNER, REPO).execute().body(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributors(expectedContributors, resultRef.get()); + } + + @Test + public void testSynchronousService_OK_WithBadEncoding() throws Throwable { + // given + val service = synchronousSetup(); + + // when: + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "us-ascii"); + + val contributors = service.contributors(OWNER, REPO).execute().body(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributorsWithWrongCharset(expectedContributors, resultRef.get()); + } + + @Test + public void testSynchronousService_FAIL() throws Throwable { + // given + val service = synchronousSetup(); + + // when: + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 500, expectedContributors, "utf-8"); + + val contributors = service.contributors(OWNER, REPO).execute().body(); + resultRef.compareAndSet(null, contributors); + }); + + // then: + assertNull(resultRef.get()); + } + + @Test + public void testSynchronousService_NOT_FOUND() throws Throwable { + // given + val service = synchronousSetup(); + + // when: + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 404, expectedContributors, "utf-8"); + + val contributors = service.contributors(OWNER, REPO).execute().body(); + log.info("contributors: {}", contributors); + resultRef.compareAndSet(null, contributors); + }); + + // then: + assertNull(resultRef.get()); + } + + private TestServices.GithubSync synchronousSetup() { + val callFactory = AsyncHttpClientCallFactory.builder().httpClient(httpClient).build(); + val retrofit = createRetrofitBuilder() + .callFactory(callFactory) + .build(); + val service = retrofit.create(TestServices.GithubSync.class); + return service; + } + // end: synchronous execution + + // begin: rxjava 1.x + @Test(dataProvider = "testRxJava1Service") + public void testRxJava1Service_OK(RxJavaCallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava1Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).toBlocking().first(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributors(expectedContributors, resultRef.get()); + } + + @Test(dataProvider = "testRxJava1Service") + public void testRxJava1Service_OK_WithBadEncoding(RxJavaCallAdapterFactory rxJavaCallAdapterFactory) + throws Throwable { + // given + val service = rxjava1Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "us-ascii"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).toBlocking().first(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributorsWithWrongCharset(expectedContributors, resultRef.get()); + } + + @Test(dataProvider = "testRxJava1Service", expectedExceptions = HttpException.class, + expectedExceptionsMessageRegExp = ".*HTTP 500 Server Error.*") + public void testRxJava1Service_HTTP_500(RxJavaCallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava1Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 500, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).toBlocking().first(); + resultRef.compareAndSet(null, contributors); + }); + } + + @Test(dataProvider = "testRxJava1Service", + expectedExceptions = HttpException.class, expectedExceptionsMessageRegExp = "HTTP 404 Not Found") + public void testRxJava1Service_NOT_FOUND(RxJavaCallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava1Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 404, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).toBlocking().first(); + resultRef.compareAndSet(null, contributors); + }); + } + + private TestServices.GithubRxJava1 rxjava1Setup(RxJavaCallAdapterFactory rxJavaCallAdapterFactory) { + val callFactory = AsyncHttpClientCallFactory.builder().httpClient(httpClient).build(); + val retrofit = createRetrofitBuilder() + .addCallAdapterFactory(rxJavaCallAdapterFactory) + .callFactory(callFactory) + .build(); + return retrofit.create(TestServices.GithubRxJava1.class); + } + + @DataProvider(name = "testRxJava1Service") + Object[][] testRxJava1Service_DataProvider() { + return new Object[][]{ + {RxJavaCallAdapterFactory.create()}, + {RxJavaCallAdapterFactory.createAsync()}, + {RxJavaCallAdapterFactory.createWithScheduler(Schedulers.io())}, + {RxJavaCallAdapterFactory.createWithScheduler(Schedulers.computation())}, + {RxJavaCallAdapterFactory.createWithScheduler(Schedulers.trampoline())}, + }; + } + // end: rxjava 1.x + + // begin: rxjava 2.x + @Test(dataProvider = "testRxJava2Service") + public void testRxJava2Service_OK(RxJava2CallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava2Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).blockingGet(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributors(expectedContributors, resultRef.get()); + } + + @Test(dataProvider = "testRxJava2Service") + public void testRxJava2Service_OK_WithBadEncoding(RxJava2CallAdapterFactory rxJavaCallAdapterFactory) + throws Throwable { + // given + val service = rxjava2Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 200, expectedContributors, "us-ascii"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).blockingGet(); + resultRef.compareAndSet(null, contributors); + }); + + // then + assertContributorsWithWrongCharset(expectedContributors, resultRef.get()); + } + + @Test(dataProvider = "testRxJava2Service", expectedExceptions = HttpException.class, + expectedExceptionsMessageRegExp = ".*HTTP 500 Server Error.*") + public void testRxJava2Service_HTTP_500(RxJava2CallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava2Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 500, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).blockingGet(); + resultRef.compareAndSet(null, contributors); + }); + } + + @Test(dataProvider = "testRxJava2Service", + expectedExceptions = HttpException.class, expectedExceptionsMessageRegExp = "HTTP 404 Not Found") + public void testRxJava2Service_NOT_FOUND(RxJava2CallAdapterFactory rxJavaCallAdapterFactory) throws Throwable { + // given + val service = rxjava2Setup(rxJavaCallAdapterFactory); + val expectedContributors = generateContributors(); + + // when + val resultRef = new AtomicReference>(); + withServer(server).run(srv -> { + configureTestServer(srv, 404, expectedContributors, "utf-8"); + + // execute retrofit request + val contributors = service.contributors(OWNER, REPO).blockingGet(); + resultRef.compareAndSet(null, contributors); + }); + } + + private TestServices.GithubRxJava2 rxjava2Setup(RxJava2CallAdapterFactory rxJavaCallAdapterFactory) { + val callFactory = AsyncHttpClientCallFactory.builder().httpClient(httpClient).build(); + val retrofit = createRetrofitBuilder() + .addCallAdapterFactory(rxJavaCallAdapterFactory) + .callFactory(callFactory) + .build(); + return retrofit.create(TestServices.GithubRxJava2.class); + } + + @DataProvider(name = "testRxJava2Service") + Object[][] testRxJava2Service_DataProvider() { + return new Object[][]{ + {RxJava2CallAdapterFactory.create()}, + {RxJava2CallAdapterFactory.createAsync()}, + {RxJava2CallAdapterFactory.createWithScheduler(io.reactivex.schedulers.Schedulers.io())}, + {RxJava2CallAdapterFactory.createWithScheduler(io.reactivex.schedulers.Schedulers.computation())}, + {RxJava2CallAdapterFactory.createWithScheduler(io.reactivex.schedulers.Schedulers.trampoline())}, + }; + } + // end: rxjava 2.x + + private Retrofit.Builder createRetrofitBuilder() { + return new Retrofit.Builder() + .addConverterFactory(ScalarsConverterFactory.create()) + .addConverterFactory(JacksonConverterFactory.create(objectMapper)) + .validateEagerly(true) + .baseUrl(server.getHttpUrl()); + } + + /** + * Asserts contributors. + * + * @param expected expected list of contributors + * @param actual actual retrieved list of contributors. + */ + private void assertContributors(Collection expected, Collection actual) { + assertNotNull(actual, "Retrieved contributors should not be null."); + log.debug("Contributors: {} ->\n {}", actual.size(), actual); + assertTrue(expected.size() == actual.size()); + assertEquals(expected, actual); + } + + private void assertContributorsWithWrongCharset(List expected, List actual) { + assertNotNull(actual, "Retrieved contributors should not be null."); + log.debug("Contributors: {} ->\n {}", actual.size(), actual); + assertTrue(expected.size() == actual.size()); + + // first and second element should have different logins due to problems with decoding utf8 to us-ascii + assertNotEquals(expected.get(0).getLogin(), actual.get(0).getLogin()); + assertEquals(expected.get(0).getContributions(), actual.get(0).getContributions()); + + assertNotEquals(expected.get(1).getLogin(), actual.get(1).getLogin()); + assertEquals(expected.get(1).getContributions(), actual.get(1).getContributions()); + + // other elements should be equal + for (int i = 2; i < expected.size(); i++) { + assertEquals(expected.get(i), actual.get(i)); + } + } + + private List generateContributors() { + val list = new ArrayList(); + + list.add(new Contributor(UUID.randomUUID() + ": čćžšđ", 100)); + list.add(new Contributor(UUID.randomUUID() + ": ČĆŽŠĐ", 200)); + + IntStream.range(0, (int) (Math.random() * 100)).forEach(i -> { + list.add(new Contributor(UUID.randomUUID().toString(), (int) (Math.random() * 500))); + }); + + return list; + } + + private HttpServer configureTestServer(HttpServer server, int status, + Collection contributors, + String charset) { + server.enqueueResponse(response -> { + response.setStatus(status); + if (status == 200) { + response.setHeader("Content-Type", "application/json; charset=" + charset); + response.getOutputStream().write(objectMapper.writeValueAsBytes(contributors)); + } else { + response.setHeader("Content-Type", "text/plain"); + val errorMsg = "This is an " + status + " error"; + response.getOutputStream().write(errorMsg.getBytes()); + } + }); + + return server; + } +} diff --git a/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/TestServices.java b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/TestServices.java new file mode 100644 index 0000000000..e7e2e77dca --- /dev/null +++ b/extras/retrofit2/src/test/java/org/asynchttpclient/extras/retrofit/TestServices.java @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2017 AsyncHttpClient Project. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ +package org.asynchttpclient.extras.retrofit; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.NonNull; +import lombok.Value; +import retrofit2.Call; +import retrofit2.http.GET; +import retrofit2.http.Path; +import rx.Observable; + +import java.io.Serializable; +import java.util.List; + +/** + * Github DTOs and services. + */ +public class TestServices { + @Value + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Contributor implements Serializable { + private static final long serialVersionUID = 1; + + @NonNull + String login; + + @NonNull + int contributions; + } + + /** + * Synchronous interface + */ + public interface GithubSync { + @GET("/repos/{owner}/{repo}/contributors") + Call> contributors(@Path("owner") String owner, @Path("repo") String repo); + } + + /** + * RxJava 1.x reactive interface + */ + public interface GithubRxJava1 { + @GET("/repos/{owner}/{repo}/contributors") + Observable> contributors(@Path("owner") String owner, @Path("repo") String repo); + } + + /** + * RxJava 2.x reactive interface + */ + public interface GithubRxJava2 { + @GET("/repos/{owner}/{repo}/contributors") + io.reactivex.Single> contributors(@Path("owner") String owner, @Path("repo") String repo); + } +}