diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9a8047e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ + +name: Build + +on: [push, pull_request] + +jobs: + build: + + runs-on: ${{ matrix.os }} + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + java_version: [17] + os: [ubuntu-latest] + + steps: + - uses: actions/checkout@v2 + - name: Set up Java + uses: actions/setup-java@v2 + with: + java-version: ${{ matrix.java_version }} + distribution: 'adopt' + - name: Maven cache + uses: actions/cache@v2 + env: + cache-name: maven-cache + with: + path: + ~/.m2 + key: build-${{ env.cache-name }} + - name: Build with Maven + run: mvn verify + diff --git a/README.md b/README.md index ae42f26..e40a5b0 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,18 @@ +# MOVED !!! + +This project has been moved to be a sub-module of avaje-http. + +It is now at: https://github.com/avaje/avaje-http/tree/master/http-client + + +----------------- + + + +[![Build](https://github.com/avaje/avaje-http-client/actions/workflows/build.yml/badge.svg)](https://github.com/avaje/avaje-http-client/actions/workflows/build.yml) +[![Maven Central](https://img.shields.io/maven-central/v/io.avaje/avaje-http-client.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.avaje/avaje-http-client) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/avaje/avaje-http-client/blob/master/LICENSE) + # avaje-http-client Documentation at [avaje.io/http-client](https://avaje.io/http-client/) @@ -20,7 +35,7 @@ A lightweight wrapper to the [JDK 11+ Java Http Client](http://openjdk.java.net/ io.avaje avaje-http-client - 1.11 + ${avaje.client.version} ``` @@ -31,11 +46,11 @@ Create a HttpClientContext with a baseUrl, Jackson or Gson based JSON ```java public HttpClientContext client() { - return HttpClientContext.newBuilder() - .withBaseUrl(baseUrl) - .withRequestListener(new RequestLogger()) - .withBodyAdapter(new JacksonBodyAdapter(new ObjectMapper())) -// .withBodyAdapter(new GsonBodyAdapter(new Gson())) + return HttpClientContext.builder() + .baseUrl(baseUrl) + .bodyAdapter(new JsonbBodyAdapter()) + //.bodyAdapter(new JacksonBodyAdapter(new ObjectMapper())) + //.bodyAdapter(new GsonBodyAdapter(new Gson())) .build(); } @@ -59,7 +74,7 @@ From HttpClientContext: ## Limitations: -- NO support for POSTing multipart-form currently +- No support for POSTing multipart-form currently - Retry (when specified) does not apply to `async` response processing` @@ -79,6 +94,15 @@ HttpResponse hres = clientContext.request() .GET() .asString(); ``` + +#### Example GET as JSON marshalling into a java class/dto +```java +CustomerDto customer = clientContext.request() + .path("customers").path(42) + .GET() + .bean(CustomerDto.class); +``` + #### Example Async GET as String - All async requests use CompletableFuture<T> - throwable is a CompletionException @@ -114,7 +138,7 @@ Overview of response types for sync calls. bean<E>E list<E>List<E> stream<E>Stream<E> -withHandler(HttpResponse.BodyHandler<E>)HttpResponse<E> +handler(HttpResponse.BodyHandler<E>)HttpResponse<E>    async processing  asVoidCompletableFuture<HttpResponse<Void>> @@ -122,13 +146,13 @@ Overview of response types for sync calls. bean<E>CompletableFuture<E> list<E>CompletableFuture<List<E>> stream<E>CompletableFuture<Stream<E>> -withHandler(HttpResponse.BodyHandler<E>)CompletableFuture<HttpResponse<E>> +handler(HttpResponse.BodyHandler<E>)CompletableFuture<HttpResponse<E>> ### HttpResponse BodyHandlers JDK HttpClient provides a number of [BodyHandlers](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpResponse.BodyHandler.html) -including reactive Flow based subscribers. With the `withHandler()` method we can use any of these or our own [`HttpResponse.BodyHandler`](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpResponse.BodyHandler.html) +including reactive Flow based subscribers. With the `handler()` method we can use any of these or our own [`HttpResponse.BodyHandler`](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpResponse.BodyHandler.html) implementation. Refer to [HttpResponse.BodyHandlers](https://docs.oracle.com/en/java/javase/11/docs/api/java.net.http/java/net/http/HttpResponse.BodyHandlers.html) @@ -286,7 +310,39 @@ HttpResponse res = clientContext.request() assertThat(res.statusCode()).isEqualTo(201); ``` +## Retry (Sync Requests Only) +To add Retry funtionality, use `.retryHandler(yourhandler)` on the builder to provide your retry handler. The `RetryHandler` interface provides two methods, one for status exceptions (e.g. you get a 4xx/5xx from the server) and another for exceptions thrown by the underlying client (e.g. server times out or client couldn't send request). Here is example implementation of `RetryHandler`. +``` +public final class ExampleRetry implements RetryHandler { + private static final int MAX_RETRIES = 2; + @Override + public boolean isRetry(int retryCount, HttpResponse response) { + + final var code = response.statusCode(); + + if (retryCount >= MAX_RETRIES || code <= 400) { + + return false; + } + + return true; + } + + @Override + public boolean isExceptionRetry(int retryCount, HttpException response) { + //unwrap the exception + final var cause = response.getCause(); + if (retryCount >= MAX_RETRIES) { + return false; + } + if (cause instanceof ConnectException) { + return true; + } + + return false; + } +``` ## Async processing @@ -303,7 +359,7 @@ The `bean()`, `list()` and `stream()` responses throw a `HttpException` if the s bean<E>CompletableFuture<E> list<E>CompletableFuture<List<E>> stream<E>CompletableFuture<Stream<E>> -withHandler(HttpResponse.BodyHandler<E>)CompletableFuture<HttpResponse<E>> +handler(HttpResponse.BodyHandler<E>)CompletableFuture<HttpResponse<E>> ### .async().asDiscarding() - HttpResponse<Void> @@ -370,7 +426,7 @@ clientContext.request() ``` -### .async().withHandler(...) - Any `Response.BodyHandler` implementation +### .async().handler(...) - Any `Response.BodyHandler` implementation The example below is a line subscriber processing response content line by line. @@ -378,7 +434,7 @@ The example below is a line subscriber processing response content line by line. CompletableFuture> future = clientContext.request() .path("hello/lineStream") .GET().async() - .withHandler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() { + .handler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() { @Override public void onSubscribe(Flow.Subscription subscription) { @@ -440,10 +496,10 @@ header ("Basic Auth"). ```java HttpClientContext clientContext = - HttpClientContext.newBuilder() - .withBaseUrl(baseUrl) + HttpClientContext.builder() + .baseUrl(baseUrl) ... - .withRequestIntercept(new BasicAuthIntercept("myUsername", "myPassword")) + + + + + + + io.avaje - junit + avaje-applog-slf4j 1.0 test - io.javalin - javalin - 4.1.1 + io.avaje + junit + 1.1 test - io.avaje - avaje-inject - 6.12 + io.javalin + javalin + 5.2.0 test io.avaje avaje-http-api - 1.12 + 1.20 test io.avaje avaje-http-hibernate-validator - 2.6 + 2.8 test @@ -83,18 +102,10 @@ - - - io.avaje - avaje-inject-generator - 6.12 - test - - - - + + @@ -102,21 +113,43 @@ + + org.apache.maven.plugins + maven-compiler-plugin + 3.10.1 + + + default-testCompile + + + + io.avaje + avaje-inject-generator + 8.10 + + + + + + + + maven-surefire-plugin - 3.0.0-M4 - - - --add-modules com.fasterxml.jackson.databind - --add-modules io.avaje.jsonb - --add-opens io.avaje.http.client/io.avaje.http.client=ALL-UNNAMED - --add-opens io.avaje.http.client/org.example.webserver=ALL-UNNAMED - --add-opens io.avaje.http.client/org.example.github=ALL-UNNAMED - --add-opens io.avaje.http.client/org.example.webserver=com.fasterxml.jackson.databind - --add-opens io.avaje.http.client/org.example.github=com.fasterxml.jackson.databind - --add-opens io.avaje.http.client/org.example.github=io.avaje.jsonb - - + 3.0.0-M6 + + + + + + + + + + + + + diff --git a/client/src/main/java/io/avaje/http/client/BodyAdapter.java b/client/src/main/java/io/avaje/http/client/BodyAdapter.java index 3c0ffa4..012e503 100644 --- a/client/src/main/java/io/avaje/http/client/BodyAdapter.java +++ b/client/src/main/java/io/avaje/http/client/BodyAdapter.java @@ -1,5 +1,6 @@ package io.avaje.http.client; +import java.lang.reflect.ParameterizedType; import java.util.List; /** @@ -23,6 +24,16 @@ public interface BodyAdapter { */ BodyReader beanReader(Class type); + /** + * Return a BodyReader to read response content and convert to a bean. + * + * @param type The bean type to convert the content to. + */ + default BodyReader beanReader(ParameterizedType type) { + throw new UnsupportedOperationException("Parameterized types not supported for this adapter"); + } + + /** * Return a BodyReader to read response content and convert to a list of beans. * @@ -30,4 +41,12 @@ public interface BodyAdapter { */ BodyReader> listReader(Class type); + /** + * Return a BodyReader to read response content and convert to a list of beans. + * + * @param type The bean type to convert the content to. + */ + default BodyReader> listReader(ParameterizedType type) { + throw new UnsupportedOperationException("Parameterized types not supported for this adapter"); + } } diff --git a/client/src/main/java/io/avaje/http/client/DBaseBuilder.java b/client/src/main/java/io/avaje/http/client/DBaseBuilder.java new file mode 100644 index 0000000..57cb5a7 --- /dev/null +++ b/client/src/main/java/io/avaje/http/client/DBaseBuilder.java @@ -0,0 +1,173 @@ +package io.avaje.http.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.avaje.inject.BeanScope; +import io.avaje.jsonb.Jsonb; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.CookieManager; +import java.net.ProxySelector; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.Executor; + +import static java.util.Objects.requireNonNull; + +abstract class DBaseBuilder { + + java.net.http.HttpClient client; + String baseUrl; + boolean requestLogging = true; + Duration connectionTimeout = Duration.ofSeconds(20); + Duration requestTimeout = Duration.ofSeconds(20); + BodyAdapter bodyAdapter; + RetryHandler retryHandler; + AuthTokenProvider authTokenProvider; + + CookieHandler cookieHandler = new CookieManager(); + java.net.http.HttpClient.Redirect redirect = java.net.http.HttpClient.Redirect.NORMAL; + java.net.http.HttpClient.Version version; + Executor executor; + ProxySelector proxy; + SSLContext sslContext; + SSLParameters sslParameters; + Authenticator authenticator; + int priority; + + final List interceptors = new ArrayList<>(); + final List listeners = new ArrayList<>(); + + void configureFromScope(BeanScope beanScope) { + if (bodyAdapter == null) { + configureBodyAdapter(beanScope); + } + if (retryHandler == null) { + configureRetryHandler(beanScope); + } + } + + private void configureRetryHandler(BeanScope beanScope) { + beanScope.getOptional(RetryHandler.class) + .ifPresent(this::setRetryHandler); + } + + private void setRetryHandler(RetryHandler retryHandler) { + this.retryHandler = retryHandler; + } + + private void configureBodyAdapter(BeanScope beanScope) { + Optional body = beanScope.getOptional(BodyAdapter.class); + if (body.isPresent()) { + bodyAdapter = body.get(); + } else if (beanScope.contains("io.avaje.jsonb.Jsonb")) { + bodyAdapter = new JsonbBodyAdapter(beanScope.get(Jsonb.class)); + } else if (beanScope.contains("com.fasterxml.jackson.databind.ObjectMapper")) { + ObjectMapper objectMapper = beanScope.get(ObjectMapper.class); + bodyAdapter = new JacksonBodyAdapter(objectMapper); + } + } + + private RequestListener buildListener() { + if (listeners.isEmpty()) { + return null; + } else if (listeners.size() == 1) { + return listeners.get(0); + } else { + return new DRequestListeners(listeners); + } + } + + private RequestIntercept buildIntercept() { + if (interceptors.isEmpty()) { + return null; + } else if (interceptors.size() == 1) { + return interceptors.get(0); + } else { + return new DRequestInterceptors(interceptors); + } + } + + private java.net.http.HttpClient defaultClient() { + final var builder = java.net.http.HttpClient.newBuilder() + .followRedirects(redirect) + .connectTimeout(connectionTimeout); + if (cookieHandler != null) { + builder.cookieHandler(cookieHandler); + } + if (version != null) { + builder.version(version); + } + if (executor != null) { + builder.executor(executor); + } + if (proxy != null) { + builder.proxy(proxy); + } + if (sslContext != null) { + builder.sslContext(sslContext); + } + if (sslParameters != null) { + builder.sslParameters(sslParameters); + } + if (authenticator != null) { + builder.authenticator(authenticator); + } + if (priority > 0) { + builder.priority(priority); + } + return builder.build(); + } + + /** + * Create a reasonable default BodyAdapter if avaje-jsonb or Jackson are present. + */ + private BodyAdapter defaultBodyAdapter() { + try { + return detectJsonb() ? new JsonbBodyAdapter() + : detectJackson() ? new JacksonBodyAdapter() + : null; + } catch (IllegalAccessError e) { + // not in module path + return null; + } + } + + private boolean detectJsonb() { + return detectTypeExists("io.avaje.jsonb.Jsonb"); + } + + private boolean detectJackson() { + return detectTypeExists("com.fasterxml.jackson.databind.ObjectMapper"); + } + + private boolean detectTypeExists(String className) { + try { + Class.forName(className); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + DHttpClientContext buildClient() { + requireNonNull(baseUrl, "baseUrl is not specified"); + requireNonNull(requestTimeout, "requestTimeout is not specified"); + if (client == null) { + client = defaultClient(); + } + if (requestLogging) { + // register the builtin request/response logging + this.listeners.add(new RequestLogger()); + } + if (bodyAdapter == null) { + bodyAdapter = defaultBodyAdapter(); + } + return new DHttpClientContext(client, baseUrl, requestTimeout, bodyAdapter, retryHandler, buildListener(), authTokenProvider, buildIntercept()); + } + +} diff --git a/client/src/main/java/io/avaje/http/client/DHttpApi.java b/client/src/main/java/io/avaje/http/client/DHttpApi.java index 3337346..643f484 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpApi.java +++ b/client/src/main/java/io/avaje/http/client/DHttpApi.java @@ -1,18 +1,19 @@ package io.avaje.http.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import io.avaje.applog.AppLog; import java.util.HashMap; import java.util.Map; import java.util.ServiceLoader; +import static java.lang.System.Logger.Level.*; + /** * Service loads the HttpApiProvider for HttpApi. */ -class DHttpApi { +final class DHttpApi { - private static final Logger log = LoggerFactory.getLogger(DHttpApi.class); + private static final System.Logger log = AppLog.getLogger("io.avaje.http.client"); private static final DHttpApi INSTANCE = new DHttpApi(); @@ -27,7 +28,7 @@ void init() { for (HttpApiProvider apiProvider : ServiceLoader.load(HttpApiProvider.class)) { addProvider(apiProvider); } - log.debug("providers for {}", providerMap.keySet()); + log.log(DEBUG, "providers for {0}", providerMap.keySet()); } void addProvider(HttpApiProvider apiProvider) { diff --git a/client/src/main/java/io/avaje/http/client/DHttpAsync.java b/client/src/main/java/io/avaje/http/client/DHttpAsync.java index dba3cce..770f6d3 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpAsync.java +++ b/client/src/main/java/io/avaje/http/client/DHttpAsync.java @@ -1,12 +1,13 @@ package io.avaje.http.client; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpResponse; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; -class DHttpAsync implements HttpAsyncResponse { +final class DHttpAsync implements HttpAsyncResponse { private final DHttpClientRequest request; @@ -21,7 +22,7 @@ private CompletableFuture> with(boolean loggable, HttpRespon } @Override - public CompletableFuture> withHandler(HttpResponse.BodyHandler handler) { + public CompletableFuture> handler(HttpResponse.BodyHandler handler) { return with(false, handler); } @@ -78,4 +79,25 @@ public CompletableFuture> stream(Class type) { .performSendAsync(false, HttpResponse.BodyHandlers.ofLines()) .thenApply(httpResponse -> request.asyncStream(type, httpResponse)); } + + @Override + public CompletableFuture bean(ParameterizedType type) { + return request + .performSendAsync(true, HttpResponse.BodyHandlers.ofByteArray()) + .thenApply(httpResponse -> request.asyncBean(type, httpResponse)); + } + + @Override + public CompletableFuture> list(ParameterizedType type) { + return request + .performSendAsync(true, HttpResponse.BodyHandlers.ofByteArray()) + .thenApply(httpResponse -> request.asyncList(type, httpResponse)); + } + + @Override + public CompletableFuture> stream(ParameterizedType type) { + return request + .performSendAsync(false, HttpResponse.BodyHandlers.ofLines()) + .thenApply(httpResponse -> request.asyncStream(type, httpResponse)); + } } diff --git a/client/src/main/java/io/avaje/http/client/DHttpCall.java b/client/src/main/java/io/avaje/http/client/DHttpCall.java index 53ec3ab..31c94e9 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpCall.java +++ b/client/src/main/java/io/avaje/http/client/DHttpCall.java @@ -1,12 +1,13 @@ package io.avaje.http.client; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpResponse; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Stream; -class DHttpCall implements HttpCallResponse { +final class DHttpCall implements HttpCallResponse { private final DHttpClientRequest request; @@ -45,7 +46,7 @@ public HttpCall> asInputStream() { } @Override - public HttpCall> withHandler(HttpResponse.BodyHandler bodyHandler) { + public HttpCall> handler(HttpResponse.BodyHandler bodyHandler) { return new CallHandler<>(bodyHandler); } @@ -64,6 +65,21 @@ public HttpCall> stream(Class type) { return new CallStream<>(type); } + @Override + public HttpCall bean(ParameterizedType type) { + return new CallBean<>(type); + } + + @Override + public HttpCall> list(ParameterizedType type) { + return new CallList<>(type); + } + + @Override + public HttpCall> stream(ParameterizedType type) { + return new CallStream<>(type); + } + private class CallVoid implements HttpCall> { @Override public HttpResponse execute() { @@ -132,46 +148,85 @@ public CompletableFuture> async() { private class CallBean implements HttpCall { private final Class type; + private final ParameterizedType genericType; + private final boolean isGeneric; + CallBean(Class type) { + this.isGeneric = false; this.type = type; + this.genericType = null; } + + CallBean(ParameterizedType type) { + this.isGeneric = true; + this.type = null; + this.genericType = type; + } + @Override public E execute() { - return request.bean(type); + return isGeneric ? request.bean(genericType) : request.bean(type); } + @Override public CompletableFuture async() { - return request.async().bean(type); + return isGeneric ? request.async().bean(genericType) : request.async().bean(type); } } private class CallList implements HttpCall> { private final Class type; + private final ParameterizedType genericType; + private final boolean isGeneric; + CallList(Class type) { + this.isGeneric = false; this.type = type; + this.genericType = null; } + + CallList(ParameterizedType type) { + this.isGeneric = true; + this.type = null; + this.genericType = type; + } + @Override public List execute() { - return request.list(type); + return isGeneric ? request.list(genericType) : request.list(type); } + @Override public CompletableFuture> async() { - return request.async().list(type); + return isGeneric ? request.async().list(genericType) : request.async().list(type); } } private class CallStream implements HttpCall> { private final Class type; + private final ParameterizedType genericType; + private final boolean isGeneric; + CallStream(Class type) { + this.isGeneric = false; this.type = type; + this.genericType = null; + } + + CallStream(ParameterizedType type) { + this.isGeneric = true; + this.type = null; + this.genericType = type; } + @Override public Stream execute() { - return request.stream(type); + return isGeneric ? request.stream(genericType) : request.stream(type); } + @Override public CompletableFuture> async() { - return request.async().stream(type); + return isGeneric ? request.async().stream(genericType) : request.async().stream(type); } } @@ -182,11 +237,11 @@ private class CallHandler implements HttpCall> { } @Override public HttpResponse execute() { - return request.withHandler(handler); + return request.handler(handler); } @Override public CompletableFuture> async() { - return request.async().withHandler(handler); + return request.async().handler(handler); } } } diff --git a/client/src/main/java/io/avaje/http/client/DHttpClientBuilder.java b/client/src/main/java/io/avaje/http/client/DHttpClientBuilder.java new file mode 100644 index 0000000..1641c23 --- /dev/null +++ b/client/src/main/java/io/avaje/http/client/DHttpClientBuilder.java @@ -0,0 +1,178 @@ +package io.avaje.http.client; + +import io.avaje.inject.BeanScope; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.time.Duration; +import java.util.concurrent.Executor; + +final class DHttpClientBuilder extends DBaseBuilder implements HttpClient.Builder, HttpClient.Builder.State { + + DHttpClientBuilder() { + } + + @Override + public HttpClient.Builder client(java.net.http.HttpClient client) { + this.client = client; + return this; + } + + @Override + public HttpClient.Builder baseUrl(String baseUrl) { + this.baseUrl = baseUrl; + return this; + } + + @Override + public HttpClient.Builder connectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + return this; + } + + @Override + public HttpClient.Builder requestTimeout(Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + + @Override + public HttpClient.Builder bodyAdapter(BodyAdapter adapter) { + this.bodyAdapter = adapter; + return this; + } + + @Override + public HttpClient.Builder retryHandler(RetryHandler retryHandler) { + this.retryHandler = retryHandler; + return this; + } + + @Override + public HttpClient.Builder requestLogging(boolean requestLogging) { + this.requestLogging = requestLogging; + return this; + } + + @Override + public HttpClient.Builder requestListener(RequestListener requestListener) { + this.listeners.add(requestListener); + return this; + } + + @Override + public HttpClient.Builder requestIntercept(RequestIntercept requestIntercept) { + this.interceptors.add(requestIntercept); + return this; + } + + @Override + public HttpClient.Builder authTokenProvider(AuthTokenProvider authTokenProvider) { + this.authTokenProvider = authTokenProvider; + return this; + } + + @Override + public HttpClient.Builder cookieHandler(CookieHandler cookieHandler) { + this.cookieHandler = cookieHandler; + return this; + } + + @Override + public HttpClient.Builder redirect(java.net.http.HttpClient.Redirect redirect) { + this.redirect = redirect; + return this; + } + + @Override + public HttpClient.Builder version(java.net.http.HttpClient.Version version) { + this.version = version; + return this; + } + + @Override + public HttpClient.Builder executor(Executor executor) { + this.executor = executor; + return this; + } + + @Override + public HttpClient.Builder proxy(ProxySelector proxySelector) { + this.proxy = proxySelector; + return this; + } + + @Override + public HttpClient.Builder sslContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + @Override + public HttpClient.Builder sslParameters(SSLParameters sslParameters) { + this.sslParameters = sslParameters; + return this; + } + + @Override + public HttpClient.Builder authenticator(Authenticator authenticator) { + this.authenticator = authenticator; + return this; + } + + @Override + public HttpClient.Builder priority(int priority) { + this.priority = priority; + return this; + } + + @Override + public HttpClient.Builder.State state() { + return this; + } + + @Override + public HttpClient.Builder configureWith(BeanScope beanScope) { + super.configureFromScope(beanScope); + return this; + } + + @Override + public HttpClient build() { + return buildClient(); + } + + @Override + public String baseUrl() { + return baseUrl; + } + + @Override + public BodyAdapter bodyAdapter() { + return bodyAdapter; + } + + @Override + public java.net.http.HttpClient client() { + return client; + } + + @Override + public boolean requestLogging() { + return requestLogging; + } + + @Override + public Duration requestTimeout() { + return requestTimeout; + } + + @Override + public RetryHandler retryHandler() { + return retryHandler; + } + +} diff --git a/client/src/main/java/io/avaje/http/client/DHttpClientContext.java b/client/src/main/java/io/avaje/http/client/DHttpClientContext.java index d44602d..cd44ab7 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpClientContext.java +++ b/client/src/main/java/io/avaje/http/client/DHttpClientContext.java @@ -2,7 +2,7 @@ import java.io.IOException; import java.lang.reflect.Constructor; -import java.net.http.HttpClient; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -12,8 +12,10 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.LongAccumulator; +import java.util.concurrent.atomic.LongAdder; -class DHttpClientContext implements HttpClientContext { +final class DHttpClientContext implements HttpClientContext, SpiHttpClient { /** * HTTP Authorization header. @@ -21,7 +23,7 @@ class DHttpClientContext implements HttpClientContext { static final String AUTHORIZATION = "Authorization"; private static final String BEARER = "Bearer "; - private final HttpClient httpClient; + private final java.net.http.HttpClient httpClient; private final String baseUrl; private final Duration requestTimeout; private final BodyAdapter bodyAdapter; @@ -31,9 +33,14 @@ class DHttpClientContext implements HttpClientContext { private final boolean withAuthToken; private final AuthTokenProvider authTokenProvider; private final AtomicReference tokenRef = new AtomicReference<>(); - private int loggingMaxBody = 1_000; - DHttpClientContext(HttpClient httpClient, String baseUrl, Duration requestTimeout, BodyAdapter bodyAdapter, RetryHandler retryHandler, RequestListener requestListener, AuthTokenProvider authTokenProvider, RequestIntercept intercept) { + private final LongAdder metricResTotal = new LongAdder(); + private final LongAdder metricResError = new LongAdder(); + private final LongAdder metricResBytes = new LongAdder(); + private final LongAdder metricResMicros = new LongAdder(); + private final LongAccumulator metricResMaxMicros = new LongAccumulator(Math::max, 0); + + DHttpClientContext(java.net.http.HttpClient httpClient, String baseUrl, Duration requestTimeout, BodyAdapter bodyAdapter, RetryHandler retryHandler, RequestListener requestListener, AuthTokenProvider authTokenProvider, RequestIntercept intercept) { this.httpClient = httpClient; this.baseUrl = baseUrl; this.requestTimeout = requestTimeout; @@ -55,21 +62,29 @@ public T create(Class clientInterface) { if (apiProvider != null) { return apiProvider.provide(this); } - String implClassName = clientImplementationClassName(clientInterface); try { - Class serviceClass = Class.forName(implClassName); - Constructor constructor = serviceClass.getConstructor(HttpClientContext.class); - Object service = constructor.newInstance(this); - return (T) service; + Class implementationClass = implementationClass(clientInterface); + Constructor constructor = implementationClass.getConstructor(HttpClientContext.class); + return (T) constructor.newInstance(this); } catch (Exception e) { - throw new IllegalStateException("Failed to create http client service " + implClassName, e); + String cn = implementationClassName(clientInterface, "HttpClient"); + throw new IllegalStateException("Failed to create http client service " + cn, e); + } + } + + private Class implementationClass(Class clientInterface) throws ClassNotFoundException { + try { + return Class.forName(implementationClassName(clientInterface, "HttpClient")); + } catch (ClassNotFoundException e) { + // try the older generated client suffix + return Class.forName(implementationClassName(clientInterface, "$HttpClient")); } } - private String clientImplementationClassName(Class clientInterface) { + private String implementationClassName(Class clientInterface, String suffix) { String packageName = clientInterface.getPackageName(); String simpleName = clientInterface.getSimpleName(); - return packageName + ".httpclient." + simpleName + "$HttpClient"; + return packageName + ".httpclient." + simpleName + suffix; } @Override @@ -80,7 +95,7 @@ public HttpClientRequest request() { } @Override - public BodyAdapter converters() { + public BodyAdapter bodyAdapter() { return bodyAdapter; } @@ -90,10 +105,80 @@ public UrlBuilder url() { } @Override - public HttpClient httpClient() { + public java.net.http.HttpClient httpClient() { return httpClient; } + @Override + public HttpClient.Metrics metrics() { + return metrics(false); + } + + @Override + public HttpClient.Metrics metrics(boolean reset) { + if (reset) { + return new DMetrics(metricResTotal.sumThenReset(), metricResError.sumThenReset(), metricResBytes.sumThenReset(), metricResMicros.sumThenReset(), metricResMaxMicros.getThenReset()); + } else { + return new DMetrics(metricResTotal.sum(), metricResError.sum(), metricResBytes.sum(), metricResMicros.sum(), metricResMaxMicros.get()); + } + } + + void metricsString(int stringBody) { + metricResBytes.add(stringBody); + } + + static final class DMetrics implements HttpClient.Metrics { + + private final long totalCount; + private final long errorCount; + private final long responseBytes; + private final long totalMicros; + private final long maxMicros; + + DMetrics(long totalCount, long errorCount, long responseBytes, long totalMicros, long maxMicros) { + this.totalCount = totalCount; + this.errorCount = errorCount; + this.responseBytes = responseBytes; + this.totalMicros = totalMicros; + this.maxMicros = maxMicros; + } + + @Override + public String toString() { + return "totalCount:" + totalCount + " errorCount:" + errorCount + " responseBytes:" + responseBytes + " totalMicros:" + totalMicros + " avgMicros:" + avgMicros()+ " maxMicros:" + maxMicros; + } + + @Override + public long totalCount() { + return totalCount; + } + + @Override + public long errorCount() { + return errorCount; + } + + @Override + public long responseBytes() { + return responseBytes; + } + + @Override + public long totalMicros() { + return totalMicros; + } + + @Override + public long maxMicros() { + return maxMicros; + } + + @Override + public long avgMicros() { + return totalCount == 0 ? 0 : totalMicros / totalCount; + } + } + @Override public void checkResponse(HttpResponse response) { if (response.statusCode() >= 300) { @@ -123,6 +208,10 @@ public BodyContent readErrorContent(boolean responseAsBytes, HttpResponse htt @Override public BodyContent readContent(HttpResponse httpResponse) { + final byte[] body = httpResponse.body(); + if (body != null && body.length > 0) { + metricResBytes.add(body.length); + } byte[] bodyBytes = decodeContent(httpResponse); final String contentType = getContentType(httpResponse); return new BodyContent(contentType, bodyBytes); @@ -184,6 +273,10 @@ BodyReader beanReader(Class cls) { return bodyAdapter.beanReader(cls); } + BodyReader beanReader(ParameterizedType cls) { + return bodyAdapter.beanReader(cls); + } + T readBean(Class cls, BodyContent content) { return bodyAdapter.beanReader(cls).read(content); } @@ -192,7 +285,22 @@ List readList(Class cls, BodyContent content) { return bodyAdapter.listReader(cls).read(content); } + @SuppressWarnings("unchecked") + T readBean(ParameterizedType cls, BodyContent content) { + return (T) bodyAdapter.beanReader(cls).read(content); + } + + List readList(ParameterizedType cls, BodyContent content) { + return (List) bodyAdapter.listReader(cls).read(content); + } + void afterResponse(DHttpClientRequest request) { + metricResTotal.add(1); + metricResMicros.add(request.responseTimeMicros()); + metricResMaxMicros.accumulate(request.responseTimeMicros()); + if (request.response().statusCode() >= 300) { + metricResError.add(1); + } if (requestListener != null) { requestListener.response(request.listenerEvent()); } @@ -220,6 +328,6 @@ private String authToken() { } String maxResponseBody(String body) { - return body.length() > loggingMaxBody ? body.substring(0, loggingMaxBody) + " ..." : body; + return body.length() > 1_000 ? body.substring(0, 1_000) + " ..." : body; } } diff --git a/client/src/main/java/io/avaje/http/client/DHttpClientContextBuilder.java b/client/src/main/java/io/avaje/http/client/DHttpClientContextBuilder.java index e81a1e5..98ba0a8 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpClientContextBuilder.java +++ b/client/src/main/java/io/avaje/http/client/DHttpClientContextBuilder.java @@ -1,41 +1,17 @@ package io.avaje.http.client; +import io.avaje.inject.BeanScope; + import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; import java.net.Authenticator; import java.net.CookieHandler; -import java.net.CookieManager; import java.net.ProxySelector; import java.net.http.HttpClient; import java.time.Duration; -import java.util.ArrayList; -import java.util.List; import java.util.concurrent.Executor; -import static java.util.Objects.requireNonNull; - -class DHttpClientContextBuilder implements HttpClientContext.Builder { - - private HttpClient client; - private String baseUrl; - private boolean requestLogging = true; - private Duration requestTimeout = Duration.ofSeconds(20); - private BodyAdapter bodyAdapter; - private RetryHandler retryHandler; - private AuthTokenProvider authTokenProvider; - - private CookieHandler cookieHandler = new CookieManager(); - private HttpClient.Redirect redirect = HttpClient.Redirect.NORMAL; - private HttpClient.Version version; - private Executor executor; - private ProxySelector proxy; - private SSLContext sslContext; - private SSLParameters sslParameters; - private Authenticator authenticator; - private int priority; - - private final List interceptors = new ArrayList<>(); - private final List listeners = new ArrayList<>(); +final class DHttpClientContextBuilder extends DBaseBuilder implements HttpClientContext.Builder, HttpClientContext.Builder.State { DHttpClientContextBuilder() { } @@ -52,6 +28,12 @@ public HttpClientContext.Builder baseUrl(String baseUrl) { return this; } + @Override + public HttpClientContext.Builder connectionTimeout(Duration connectionTimeout) { + this.connectionTimeout = connectionTimeout; + return this; + } + @Override public HttpClientContext.Builder requestTimeout(Duration requestTimeout) { this.requestTimeout = requestTimeout; @@ -148,69 +130,50 @@ public HttpClientContext.Builder priority(int priority) { return this; } + @Override + public State state() { + return this; + } + + @Override + public HttpClientContext.Builder configureWith(BeanScope beanScope) { + super.configureFromScope(beanScope); + return this; + } + @Override public HttpClientContext build() { - requireNonNull(baseUrl, "baseUrl is not specified"); - requireNonNull(requestTimeout, "requestTimeout is not specified"); - if (client == null) { - client = defaultClient(); - } - if (requestLogging) { - // register the built in request/response logging - requestListener(new RequestLogger()); - } - return new DHttpClientContext(client, baseUrl, requestTimeout, bodyAdapter, retryHandler, buildListener(), authTokenProvider, buildIntercept()); - } - - private RequestListener buildListener() { - if (listeners.isEmpty()) { - return null; - } else if (listeners.size() == 1) { - return listeners.get(0); - } else { - return new DRequestListeners(listeners); - } - } - - private RequestIntercept buildIntercept() { - if (interceptors.isEmpty()) { - return null; - } else if (interceptors.size() == 1) { - return interceptors.get(0); - } else { - return new DRequestInterceptors(interceptors); - } - } - - private HttpClient defaultClient() { - final HttpClient.Builder builder = HttpClient.newBuilder() - .followRedirects(redirect) - .connectTimeout(Duration.ofSeconds(20)); - if (cookieHandler != null) { - builder.cookieHandler(cookieHandler); - } - if (version != null) { - builder.version(version); - } - if (executor != null) { - builder.executor(executor); - } - if (proxy != null) { - builder.proxy(proxy); - } - if (sslContext != null) { - builder.sslContext(sslContext); - } - if (sslParameters != null) { - builder.sslParameters(sslParameters); - } - if (authenticator != null) { - builder.authenticator(authenticator); - } - if (priority > 0) { - builder.priority(priority); - } - return builder.build(); + return super.buildClient(); + } + + @Override + public String baseUrl() { + return baseUrl; + } + + @Override + public BodyAdapter bodyAdapter() { + return bodyAdapter; + } + + @Override + public HttpClient client() { + return client; + } + + @Override + public boolean requestLogging() { + return requestLogging; + } + + @Override + public Duration requestTimeout() { + return requestTimeout; + } + + @Override + public RetryHandler retryHandler() { + return retryHandler; } } diff --git a/client/src/main/java/io/avaje/http/client/DHttpClientRequest.java b/client/src/main/java/io/avaje/http/client/DHttpClientRequest.java index 435086f..fd69887 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpClientRequest.java +++ b/client/src/main/java/io/avaje/http/client/DHttpClientRequest.java @@ -3,6 +3,7 @@ import javax.net.ssl.SSLSession; import java.io.FileNotFoundException; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; @@ -436,7 +437,32 @@ public List list(Class cls) { @Override public Stream stream(Class cls) { - final HttpResponse> res = withHandler(HttpResponse.BodyHandlers.ofLines()); + final HttpResponse> res = handler(HttpResponse.BodyHandlers.ofLines()); + this.httpResponse = res; + if (res.statusCode() >= 300) { + throw new HttpException(res, context); + } + final BodyReader bodyReader = context.beanReader(cls); + return res.body().map(bodyReader::readBody); + } + + + @Override + public T bean(ParameterizedType cls) { + readResponseContent(); + return context.readBean(cls, encodedResponseBody); + } + + @Override + public List list(ParameterizedType cls) { + readResponseContent(); + return context.readList(cls, encodedResponseBody); + } + + + @Override + public Stream stream(ParameterizedType cls) { + final HttpResponse> res = handler(HttpResponse.BodyHandlers.ofLines()); this.httpResponse = res; if (res.statusCode() >= 300) { throw new HttpException(res, context); @@ -446,7 +472,7 @@ public Stream stream(Class cls) { } @Override - public HttpResponse withHandler(HttpResponse.BodyHandler responseHandler) { + public HttpResponse handler(HttpResponse.BodyHandler responseHandler) { final HttpResponse response = sendWith(responseHandler); context.afterResponse(this); return response; @@ -506,6 +532,27 @@ protected Stream asyncStream(Class type, HttpResponse> return response.body().map(bodyReader::readBody); } + protected E asyncBean(ParameterizedType type, HttpResponse response) { + afterAsyncEncoded(response); + return context.readBean(type, encodedResponseBody); + } + + protected List asyncList(ParameterizedType type, HttpResponse response) { + afterAsyncEncoded(response); + return context.readList(type, encodedResponseBody); + } + + protected Stream asyncStream(ParameterizedType type, HttpResponse> response) { + responseTimeNanos = System.nanoTime() - startAsyncNanos; + httpResponse = response; + context.afterResponse(this); + if (response.statusCode() >= 300) { + throw new HttpException(response, context); + } + final BodyReader bodyReader = context.beanReader(type); + return response.body().map(bodyReader::readBody); + } + private void afterAsyncEncoded(HttpResponse response) { responseTimeNanos = System.nanoTime() - startAsyncNanos; httpResponse = response; @@ -528,41 +575,46 @@ public long responseTimeMicros() { @Override public HttpResponse asByteArray() { - return withHandler(HttpResponse.BodyHandlers.ofByteArray()); + return handler(HttpResponse.BodyHandlers.ofByteArray()); + } + + private HttpResponse addMetrics(final HttpResponse res) { + context.metricsString(res.body().length()); + return res; } @Override public HttpResponse asString() { loggableResponseBody = true; - return withHandler(HttpResponse.BodyHandlers.ofString()); + return addMetrics(handler(HttpResponse.BodyHandlers.ofString())); } @Override public HttpResponse asPlainString() { loggableResponseBody = true; - final HttpResponse hres = withHandler(HttpResponse.BodyHandlers.ofString()); + final HttpResponse hres = addMetrics(handler(HttpResponse.BodyHandlers.ofString())); context.checkResponse(hres); return hres; } @Override public HttpResponse asDiscarding() { - return withHandler(discarding()); + return handler(discarding()); } @Override public HttpResponse asInputStream() { - return withHandler(HttpResponse.BodyHandlers.ofInputStream()); + return handler(HttpResponse.BodyHandlers.ofInputStream()); } @Override public HttpResponse asFile(Path file) { - return withHandler(HttpResponse.BodyHandlers.ofFile(file)); + return handler(HttpResponse.BodyHandlers.ofFile(file)); } @Override public HttpResponse> asLines() { - return withHandler(HttpResponse.BodyHandlers.ofLines()); + return handler(HttpResponse.BodyHandlers.ofLines()); } private HttpRequest.Builder newReq(String url) { diff --git a/client/src/main/java/io/avaje/http/client/DHttpClientRequestWithRetry.java b/client/src/main/java/io/avaje/http/client/DHttpClientRequestWithRetry.java index 47ff59e..eb7cdbf 100644 --- a/client/src/main/java/io/avaje/http/client/DHttpClientRequestWithRetry.java +++ b/client/src/main/java/io/avaje/http/client/DHttpClientRequestWithRetry.java @@ -6,7 +6,7 @@ /** * Extends DHttpClientRequest with retry attempts. */ -class DHttpClientRequestWithRetry extends DHttpClientRequest { +final class DHttpClientRequestWithRetry extends DHttpClientRequest { private final RetryHandler retryHandler; private int retryCount; @@ -22,14 +22,34 @@ class DHttpClientRequestWithRetry extends DHttpClientRequest { @Override protected HttpResponse performSend(HttpResponse.BodyHandler responseHandler) { HttpResponse res; - res = super.performSend(responseHandler); - if (res.statusCode() < 300) { - return res; - } - while (retryHandler.isRetry(retryCount++, res)) { - res = super.performSend(responseHandler); + HttpException ex; + + do { + try { + res = super.performSend(responseHandler); + ex = null; + } catch (final HttpException e) { + ex = e; + res = null; + } + if (res != null && res.statusCode() < 300) { + return res; + } + retryCount++; + } while (retry(res, ex)); + + if (res == null && ex != null) { + throw ex; } + return res; } + protected boolean retry(HttpResponse res, HttpException ex) { + + if (res != null) { + return retryHandler.isRetry(retryCount, res); + } + return retryHandler.isExceptionRetry(retryCount, ex); + } } diff --git a/client/src/main/java/io/avaje/http/client/DRequestInterceptors.java b/client/src/main/java/io/avaje/http/client/DRequestInterceptors.java index 3ae70ae..0051e70 100644 --- a/client/src/main/java/io/avaje/http/client/DRequestInterceptors.java +++ b/client/src/main/java/io/avaje/http/client/DRequestInterceptors.java @@ -10,7 +10,7 @@ *

* Noting that afterResponse interceptors are processed in reverse order. */ -class DRequestInterceptors implements RequestIntercept { +final class DRequestInterceptors implements RequestIntercept { private final List before; private final List after; diff --git a/client/src/main/java/io/avaje/http/client/DRequestListeners.java b/client/src/main/java/io/avaje/http/client/DRequestListeners.java index 5b07671..6dda3b8 100644 --- a/client/src/main/java/io/avaje/http/client/DRequestListeners.java +++ b/client/src/main/java/io/avaje/http/client/DRequestListeners.java @@ -2,7 +2,7 @@ import java.util.List; -class DRequestListeners implements RequestListener { +final class DRequestListeners implements RequestListener { private final RequestListener[] listeners; diff --git a/client/src/main/java/io/avaje/http/client/GzipUtil.java b/client/src/main/java/io/avaje/http/client/GzipUtil.java index 4b7cba1..5ecced6 100644 --- a/client/src/main/java/io/avaje/http/client/GzipUtil.java +++ b/client/src/main/java/io/avaje/http/client/GzipUtil.java @@ -7,7 +7,7 @@ import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; -class GzipUtil { +final class GzipUtil { static byte[] gzip(String content) { return gzip(content.getBytes(StandardCharsets.UTF_8)); diff --git a/client/src/main/java/io/avaje/http/client/HttpAsyncResponse.java b/client/src/main/java/io/avaje/http/client/HttpAsyncResponse.java index 8cfc213..7e6dee8 100644 --- a/client/src/main/java/io/avaje/http/client/HttpAsyncResponse.java +++ b/client/src/main/java/io/avaje/http/client/HttpAsyncResponse.java @@ -1,6 +1,7 @@ package io.avaje.http.client; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpResponse; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -8,6 +9,55 @@ /** * Async processing of the request with responses as CompletableFuture. + * + *

Testing and .join()

+ *

+ * Note that when testing with async requests we frequently use {@code .join()} + * on the {@code CompletableFuture} such that the main thread waits for the async + * processing to complete. After that various asserts can run knowing that the + * async callback code has been executed. + * + *

Example using .join() for testing purposes

+ *
{@code
+ *
+ *    clientContext.request()
+ *       ...
+ *       .POST().async()
+ *       .bean(HelloDto.class)
+ *       .whenComplete((helloDto, throwable) -> {
+ *         ...
+ *       }).join(); // wait for async processing to complete
+ *
+ *       // can assert now ...
+ *       assertThat(...)
+ *
+ * }
+ * + *

Example async().bean()

+ *

+ * In this example POST async that will return a bean converted from json response. + *

{@code
+ *
+ *    clientContext.request()
+ *       ...
+ *       .POST().async()
+ *       .bean(HelloDto.class)
+ *       .whenComplete((helloDto, throwable) -> {
+ *
+ *         if (throwable != null) {
+ *           HttpException httpException = (HttpException) throwable.getCause();
+ *           int statusCode = httpException.statusCode();
+ *
+ *           // maybe convert json error response body to a bean (using Jackson/Gson)
+ *           MyErrorBean errorResponse = httpException.bean(MyErrorBean.class);
+ *           ..
+ *
+ *         } else {
+ *           // process helloDto
+ *           ...
+ *         }
+ *       });
+ * }
*/ public interface HttpAsyncResponse { @@ -144,7 +194,7 @@ public interface HttpAsyncResponse { * CompletableFuture> future = clientContext.request() * .path("hello/lineStream") * .GET().async() - * .withHandler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() { + * .handler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() { * * @Override * public void onSubscribe(Flow.Subscription subscription) { @@ -172,7 +222,15 @@ public interface HttpAsyncResponse { * @param bodyHandler The body handler to use to process the response * @return The CompletableFuture of the response */ - CompletableFuture> withHandler(HttpResponse.BodyHandler bodyHandler); + CompletableFuture> handler(HttpResponse.BodyHandler bodyHandler); + + /** + * Deprecated - migrate to handler(). + */ + @Deprecated + default CompletableFuture> withHandler(HttpResponse.BodyHandler bodyHandler) { + return handler(bodyHandler); + } /** * Process expecting a bean response body (typically from json content). @@ -277,4 +335,29 @@ public interface HttpAsyncResponse { * @return The CompletableFuture of the response */ CompletableFuture> stream(Class type); + + /** + * Process expecting a bean response body (typically from json content). + * + * @param type The parameterized type to convert the content to + * @return The CompletableFuture of the response + */ + CompletableFuture bean(ParameterizedType type); + + /** + * Process expecting a list of beans response body (typically from json content). + * + * @param type The parameterized type to convert the content to + * @return The CompletableFuture of the response + */ + CompletableFuture> list(ParameterizedType type); + + /** + * Process response as a stream of beans (x-json-stream). + * + * @param type The parameterized type to convert the content to + * @return The CompletableFuture of the response + */ + CompletableFuture> stream(ParameterizedType type); + } diff --git a/client/src/main/java/io/avaje/http/client/HttpCallResponse.java b/client/src/main/java/io/avaje/http/client/HttpCallResponse.java index 840c952..96cccbe 100644 --- a/client/src/main/java/io/avaje/http/client/HttpCallResponse.java +++ b/client/src/main/java/io/avaje/http/client/HttpCallResponse.java @@ -1,6 +1,7 @@ package io.avaje.http.client; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpResponse; import java.util.List; import java.util.stream.Stream; @@ -109,13 +110,21 @@ public interface HttpCallResponse { * HttpCall call = clientContext.request() * .path("hello/lineStream") * .GET() - * .call().withHandler(HttpResponse.BodyHandler ...); + * .call().handler(HttpResponse.BodyHandler ...); * } * * @param bodyHandler The response body handler to use * @return The HttpCall to allow sync or async execution */ - HttpCall> withHandler(HttpResponse.BodyHandler bodyHandler); + HttpCall> handler(HttpResponse.BodyHandler bodyHandler); + + /** + * Deprecated - migrate to handler(). + */ + @Deprecated + default HttpCall> withHandler(HttpResponse.BodyHandler bodyHandler) { + return handler(bodyHandler); + } /** * A bean response to execute async or sync. @@ -178,4 +187,28 @@ public interface HttpCallResponse { */ HttpCall> stream(Class type); + /** + * A bean response to execute async or sync. + * + * @param type The parameterized type to convert the content to + * @return The HttpCall to allow sync or async execution + */ + HttpCall bean(ParameterizedType type); + + /** + * Process expecting a list of beans response body (typically from json content). + * + * @param type The parameterized type to convert the content to + * @return The HttpCall to execute sync or async + */ + HttpCall> list(ParameterizedType type); + + /** + * Process expecting a stream of beans response body (typically from json content). + * + * @param type The parameterized type to convert the content to + * @return The HttpCall to execute sync or async + */ + HttpCall> stream(ParameterizedType type); + } diff --git a/client/src/main/java/io/avaje/http/client/HttpClient.java b/client/src/main/java/io/avaje/http/client/HttpClient.java new file mode 100644 index 0000000..ca95012 --- /dev/null +++ b/client/src/main/java/io/avaje/http/client/HttpClient.java @@ -0,0 +1,354 @@ +package io.avaje.http.client; + +import io.avaje.inject.BeanScope; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import java.net.Authenticator; +import java.net.CookieHandler; +import java.net.ProxySelector; +import java.time.Duration; +import java.util.concurrent.Executor; + +/** + * The HTTP client context that we use to build and process requests. + * + *
{@code
+ *
+ *   HttpClient client = HttpClient.builder()
+ *       .baseUrl("http://localhost:8080")
+ *       .bodyAdapter(new JacksonBodyAdapter())
+ *       .build();
+ *
+ *  HelloDto dto = client.request()
+ *       .path("hello")
+ *       .queryParam("name", "Rob")
+ *       .queryParam("say", "Whats up")
+ *       .GET()
+ *       .bean(HelloDto.class);
+ *
+ * }
+ */ +public interface HttpClient { + + /** + * Return the builder to config and build the client context. + * + *
{@code
+   *
+   *   HttpClient client = HttpClient.builder()
+   *       .baseUrl("http://localhost:8080")
+   *       .bodyAdapter(new JacksonBodyAdapter())
+   *       .build();
+   *
+   *  HttpResponse res = client.request()
+   *       .path("hello")
+   *       .GET().asString();
+   *
+   * }
+ */ + static Builder builder() { + return new DHttpClientBuilder(); + } + + /** + * Return the http client API implementation. + * + * @param clientInterface A @Client interface with annotated API methods. + * @param The service type. + * @return The http client API implementation. + */ + T create(Class clientInterface); + + /** + * Create a new request. + */ + HttpClientRequest request(); + + /** + * Deprecated - migrate to {@link #bodyAdapter()}. + *

+ * Return the body adapter used by the client context. + *

+ * This is the body adapter used to convert request and response + * bodies to java types. For example using Jackson with JSON payloads. + */ + @Deprecated + default BodyAdapter converters() { + return bodyAdapter(); + } + + /** + * Return the BodyAdapter that this client is using. + */ + BodyAdapter bodyAdapter(); + + /** + * Return the current aggregate metrics. + *

+ * These metrics are collected for all requests sent via this context. + */ + HttpClient.Metrics metrics(); + + /** + * Return the current metrics with the option of resetting the underlying counters. + *

+ * These metrics are collected for all requests sent via this context. + */ + HttpClient.Metrics metrics(boolean reset); + + /** + * Builds the HttpClient. + * + *

{@code
+   *
+   *   HttpClient client = HttpClient.builder()
+   *       .baseUrl("http://localhost:8080")
+   *       .bodyAdapter(new JacksonBodyAdapter())
+   *       .build();
+   *
+   *  HelloDto dto = client.request()
+   *       .path("hello")
+   *       .queryParam("name", "Rob")
+   *       .queryParam("say", "Whats up")
+   *       .GET()
+   *       .bean(HelloDto.class);
+   *
+   * }
+ */ + interface Builder { + + /** + * Set the base URL to use for requests created from the context. + *

+ * Note that the base url can be replaced via {@link HttpClientRequest#url(String)}. + */ + Builder baseUrl(String baseUrl); + + /** + * Set the connection timeout to use. + * + * @see java.net.http.HttpClient.Builder#connectTimeout(Duration) + */ + Builder connectionTimeout(Duration connectionTimeout); + + /** + * Set the default request timeout. + * + * @see java.net.http.HttpRequest.Builder#timeout(Duration) + */ + Builder requestTimeout(Duration requestTimeout); + + /** + * Set the body adapter to use to convert beans to body content + * and response content back to beans. + */ + Builder bodyAdapter(BodyAdapter adapter); + + /** + * Set a RetryHandler to use to retry requests. + */ + Builder retryHandler(RetryHandler retryHandler); + + /** + * Disable or enable built in request and response logging. + *

+ * By default request logging is enabled. Set this to false to stop + * the default {@link RequestLogger} being registered to log request + * and response headers and bodies etc. + *

+ * With logging level set to {@code DEBUG} for + * {@code io.avaje.http.client.RequestLogger} the request and response + * are logged as a summary with response status and time. + *

+ * Set the logging level to {@code TRACE} to include the request + * and response headers and body payloads with truncation for large + * bodies. + * + *

Suppression

+ *

+ * We can also use {@link HttpClientRequest#suppressLogging()} to suppress + * logging on specific requests. + *

+ * Logging of Authorization headers is suppressed. + * {@link AuthTokenProvider} requests are suppressed. + * + * @param requestLogging Disable/enable the registration of the default logger + * @see RequestLogger + */ + Builder requestLogging(boolean requestLogging); + + /** + * Add a request listener. Multiple listeners may be added, when + * do so they will process events in the order they were added. + *

+ * Note that {@link RequestLogger} is an implementation for debug + * logging request/response headers and content which is registered + * by default depending on {@link #requestLogging(boolean)}. + * + * @see RequestLogger + */ + Builder requestListener(RequestListener requestListener); + + /** + * Add a request interceptor. Multiple interceptors may be added. + */ + Builder requestIntercept(RequestIntercept requestIntercept); + + /** + * Add a Authorization token provider. + *

+ * When set all requests are expected to use a Authorization Bearer token + * unless they are marked via {@link HttpClientRequest#skipAuthToken()}. + *

+ * The AuthTokenProvider obtains a new token typically with an expiry. This + * is automatically called as needed and the Authorization Bearer header set + * on all requests (not marked with skipAuthToken()). + */ + Builder authTokenProvider(AuthTokenProvider authTokenProvider); + + /** + * Set the underlying HttpClient to use. + *

+ * Used when we wish to control all options of the HttpClient. + */ + Builder client(java.net.http.HttpClient client); + + /** + * Specify a cookie handler to use on the HttpClient. This would override the default cookie handler. + * + * @see java.net.http.HttpClient.Builder#cookieHandler(CookieHandler) + */ + Builder cookieHandler(CookieHandler cookieHandler); + + /** + * Specify the redirect policy. Defaults to HttpClient.Redirect.NORMAL. + * + * @see java.net.http.HttpClient.Builder#followRedirects(java.net.http.HttpClient.Redirect) + */ + Builder redirect(java.net.http.HttpClient.Redirect redirect); + + /** + * Specify the HTTP version. Defaults to not set. + * + * @see java.net.http.HttpClient.Builder#version(java.net.http.HttpClient.Version) + */ + Builder version(java.net.http.HttpClient.Version version); + + /** + * Specify the Executor to use for asynchronous tasks. + * If not specified a default executor will be used. + * + * @see java.net.http.HttpClient.Builder#executor(Executor) + */ + Builder executor(Executor executor); + + /** + * Set the proxy to the underlying {@link java.net.http.HttpClient}. + * + * @see java.net.http.HttpClient.Builder#proxy(ProxySelector) + */ + Builder proxy(ProxySelector proxySelector); + + /** + * Set the sslContext to the underlying {@link java.net.http.HttpClient}. + * + * @see java.net.http.HttpClient.Builder#sslContext(SSLContext) + */ + Builder sslContext(SSLContext sslContext); + + /** + * Set the sslParameters to the underlying {@link java.net.http.HttpClient}. + * + * @see java.net.http.HttpClient.Builder#sslParameters(SSLParameters) + */ + Builder sslParameters(SSLParameters sslParameters); + + /** + * Set a HttpClient authenticator to the underlying {@link java.net.http.HttpClient}. + * + * @see java.net.http.HttpClient.Builder#authenticator(Authenticator) + */ + Builder authenticator(Authenticator authenticator); + + /** + * Set the priority for HTTP/2 requests to the underlying {@link java.net.http.HttpClient}. + * + * @see java.net.http.HttpClient.Builder#priority(int) + */ + Builder priority(int priority); + + /** + * Configure BodyAdapter and RetryHandler using dependency injection BeanScope. + */ + Builder configureWith(BeanScope beanScope); + + /** + * Return the state of the builder. + */ + Builder.State state(); + + /** + * Build and return the context. + * + *

{@code
+     *
+     *   HttpClient client = HttpClient.builder()
+     *       .baseUrl("http://localhost:8080")
+     *       .bodyAdapter(new JacksonBodyAdapter())
+     *       .build();
+     *
+     *  HelloDto dto = client.request()
+     *       .path("hello")
+     *       .queryParam("say", "Whats up")
+     *       .GET()
+     *       .bean(HelloDto.class);
+     *
+     * }
+ */ + HttpClient build(); + + /** + * The state of the builder with methods to read the set state. + */ + interface State { + + /** + * Return the base URL. + */ + String baseUrl(); + + /** + * Return the body adapter. + */ + BodyAdapter bodyAdapter(); + + /** + * Return the HttpClient. + */ + java.net.http.HttpClient client(); + + /** + * Return true if requestLogging is on. + */ + boolean requestLogging(); + + /** + * Return the request timeout. + */ + Duration requestTimeout(); + + /** + * Return the retry handler. + */ + RetryHandler retryHandler(); + } + } + + /** + * Statistic metrics collected to provide an overview of activity of this client. + */ + interface Metrics extends HttpClientContext.Metrics { + + } +} diff --git a/client/src/main/java/io/avaje/http/client/HttpClientContext.java b/client/src/main/java/io/avaje/http/client/HttpClientContext.java index 139c63b..12ce33d 100644 --- a/client/src/main/java/io/avaje/http/client/HttpClientContext.java +++ b/client/src/main/java/io/avaje/http/client/HttpClientContext.java @@ -1,21 +1,25 @@ package io.avaje.http.client; +import io.avaje.inject.BeanScope; + import javax.net.ssl.SSLContext; import javax.net.ssl.SSLParameters; import java.net.Authenticator; import java.net.CookieHandler; import java.net.ProxySelector; import java.net.http.HttpClient; -import java.net.http.HttpResponse; import java.time.Duration; import java.util.concurrent.Executor; /** + * Deprecated in favor of {@link io.avaje.http.client.HttpClient}. + * Migrate to using {@link io.avaje.http.client.HttpClient#builder()}. + *

* The HTTP client context that we use to build and process requests. * *

{@code
  *
- *   HttpClientContext ctx = HttpClientContext.newBuilder()
+ *   HttpClientContext ctx = HttpClientContext.builder()
  *       .baseUrl("http://localhost:8080")
  *       .bodyAdapter(new JacksonBodyAdapter())
  *       .build();
@@ -29,14 +33,17 @@
  *
  * }
*/ -public interface HttpClientContext { +@Deprecated +public interface HttpClientContext extends io.avaje.http.client.HttpClient { /** + * Deprecated - migrate to {@link io.avaje.http.client.HttpClient#builder()}. + *

* Return the builder to config and build the client context. * *

{@code
    *
-   *   HttpClientContext ctx = HttpClientContext.newBuilder()
+   *   HttpClientContext ctx = HttpClientContext.builder()
    *       .baseUrl("http://localhost:8080")
    *       .bodyAdapter(new JacksonBodyAdapter())
    *       .build();
@@ -47,80 +54,25 @@ public interface HttpClientContext {
    *
    * }
*/ - static HttpClientContext.Builder newBuilder() { + @Deprecated + static HttpClientContext.Builder builder() { return new DHttpClientContextBuilder(); } /** - * Return the http client API implementation. - * - * @param clientInterface A @Client interface with annotated API methods. - * @param The service type. - * @return The http client API implementation. - */ - T create(Class clientInterface); - - /** - * Create a new request. - */ - HttpClientRequest request(); - - /** - * Return a UrlBuilder to use to build an URL taking into - * account the base URL. - */ - UrlBuilder url(); - - /** - * Return the body adapter used by the client context. - *

- * This is the body adapter used to convert request and response - * bodies to java types. For example using Jackson with JSON payloads. + * Deprecated - migrate to builder(). */ - BodyAdapter converters(); - - /** - * Return the underlying http client. - */ - HttpClient httpClient(); - - /** - * Check the response status code and throw HttpException if the status - * code is in the error range. - */ - void checkResponse(HttpResponse response); - - /** - * Return the response content taking into account content encoding. - * - * @param httpResponse The HTTP response to decode the content from - * @return The decoded content - */ - BodyContent readContent(HttpResponse httpResponse); - - /** - * Decode the response content given the Content-Encoding http header. - * - * @param httpResponse The HTTP response - * @return The decoded content - */ - byte[] decodeContent(HttpResponse httpResponse); - - /** - * Decode the body using the given encoding. - * - * @param encoding The encoding used to decode the content - * @param content The raw content being decoded - * @return The decoded content - */ - byte[] decodeContent(String encoding, byte[] content); + @Deprecated + static HttpClientContext.Builder newBuilder() { + return builder(); + } /** * Builds the HttpClientContext. * *

{@code
    *
-   *   HttpClientContext ctx = HttpClientContext.newBuilder()
+   *   HttpClientContext ctx = HttpClientContext.builder()
    *       .baseUrl("http://localhost:8080")
    *       .bodyAdapter(new JacksonBodyAdapter())
    *       .build();
@@ -150,6 +102,13 @@ interface Builder {
      */
     Builder baseUrl(String baseUrl);
 
+    /**
+     * Set the connection timeout to use.
+     *
+     * @see java.net.http.HttpClient.Builder#connectTimeout(Duration)
+     */
+    Builder connectionTimeout(Duration connectionTimeout);
+
     /**
      * Set the default request timeout.
      *
@@ -289,12 +248,22 @@ interface Builder {
      */
     Builder priority(int priority);
 
+    /**
+     * Configure BodyAdapter and RetryHandler using dependency injection BeanScope.
+     */
+    Builder configureWith(BeanScope beanScope);
+
+    /**
+     * Return the state of the builder.
+     */
+    State state();
+
     /**
      * Build and return the context.
      *
      * 
{@code
      *
-     *   HttpClientContext ctx = HttpClientContext.newBuilder()
+     *   HttpClientContext ctx = HttpClientContext.builder()
      *       .baseUrl("http://localhost:8080")
      *       .bodyAdapter(new JacksonBodyAdapter())
      *       .build();
@@ -308,5 +277,47 @@ interface Builder {
      * }
*/ HttpClientContext build(); + + /** + * The state of the builder with methods to read the set state. + */ + interface State extends io.avaje.http.client.HttpClient.Builder.State { + + } + } + + /** + * Statistic metrics collected to provide an overview of activity of this client. + */ + interface Metrics { + /** + * Return the total number of responses. + */ + long totalCount(); + + /** + * Return the total number of error responses (status code >= 300). + */ + long errorCount(); + + /** + * Return the total response bytes (excludes streaming responses). + */ + long responseBytes(); + + /** + * Return the total response time in microseconds. + */ + long totalMicros(); + + /** + * Return the max response time in microseconds (since the last reset). + */ + long maxMicros(); + + /** + * Return the average response time in microseconds. + */ + long avgMicros(); } } diff --git a/client/src/main/java/io/avaje/http/client/HttpClientResponse.java b/client/src/main/java/io/avaje/http/client/HttpClientResponse.java index 67e1ee8..13f4e65 100644 --- a/client/src/main/java/io/avaje/http/client/HttpClientResponse.java +++ b/client/src/main/java/io/avaje/http/client/HttpClientResponse.java @@ -1,6 +1,7 @@ package io.avaje.http.client; import java.io.InputStream; +import java.lang.reflect.ParameterizedType; import java.net.http.HttpResponse; import java.nio.file.Path; import java.util.List; @@ -14,6 +15,32 @@ public interface HttpClientResponse { /** * Send the request async using CompletableFuture. + * + *

Example async().bean()

+ *

+ * In this example POST async that will return a bean converted from json response. + *

{@code
+   *
+   *    clientContext.request()
+   *       ...
+   *       .POST().async()
+   *       .bean(HelloDto.class)
+   *       .whenComplete((helloDto, throwable) -> {
+   *
+   *         if (throwable != null) {
+   *           HttpException httpException = (HttpException) throwable.getCause();
+   *           int statusCode = httpException.statusCode();
+   *
+   *           // maybe convert json error response body to a bean (using Jackson/Gson)
+   *           MyErrorBean errorResponse = httpException.bean(MyErrorBean.class);
+   *           ..
+   *
+   *         } else {
+   *           // process helloDto
+   *           ...
+   *         }
+   *       });
+   * }
*/ HttpAsyncResponse async(); @@ -59,6 +86,7 @@ public interface HttpClientResponse { */ List list(Class type); + /** * Return the response as a stream of beans. *

@@ -80,6 +108,51 @@ public interface HttpClientResponse { */ Stream stream(Class type); + /** + * Return the response as a single bean. + *

+ * If the HTTP statusCode is not in the 2XX range a HttpException is throw which contains + * the HttpResponse. This is the cause in the CompletionException. + * + * @param type The parameterized type of the bean to convert the response content into. + * @return The bean the response is converted into. + * @throws HttpException when the response has error status codes + */ + T bean(ParameterizedType type); + + /** + * Return the response as a list of beans. + *

+ * If the HTTP statusCode is not in the 2XX range a HttpException is throw which contains + * the HttpResponse. This is the cause in the CompletionException. + * + * @param type The parameterized type of the bean to convert the response content into. + * @return The list of beans the response is converted into. + * @throws HttpException when the response has error status codes + */ + List list(ParameterizedType type); + + /** + * Return the response as a stream of beans. + *

+ * Typically the response is expected to be {@literal application/x-json-stream} + * newline delimited json payload. + *

+ * Note that for this stream request the response content is not deemed + * 'loggable' by avaje-http-client. This is because the entire response + * may not be available at the time of the callback. As such {@link RequestLogger} + * will not include response content when logging stream request/response + *

+ * If the HTTP statusCode is not in the 2XX range a HttpException is throw which contains + * the HttpResponse. This is the cause in the CompletionException. + * + * @param type The parameterized type of the bean to convert the response content into. + * @return The stream of beans from the response + * @throws HttpException when the response has error status codes + */ + Stream stream(ParameterizedType type); + + /** * Return the response with check for 200 range status code. *

@@ -141,6 +214,14 @@ public interface HttpClientResponse { /** * Return the response using the given response body handler. */ - HttpResponse withHandler(HttpResponse.BodyHandler responseHandler); + HttpResponse handler(HttpResponse.BodyHandler responseHandler); + + /** + * Deprecated - migrate to handler(). + */ + @Deprecated + default HttpResponse withHandler(HttpResponse.BodyHandler responseHandler) { + return handler(responseHandler); + } } diff --git a/client/src/main/java/io/avaje/http/client/HttpException.java b/client/src/main/java/io/avaje/http/client/HttpException.java index fe0fbdb..0c83020 100644 --- a/client/src/main/java/io/avaje/http/client/HttpException.java +++ b/client/src/main/java/io/avaje/http/client/HttpException.java @@ -91,28 +91,37 @@ public HttpException(int statusCode, Throwable cause) { } /** - * Return the response body content as a bean + * Return the response body content as a bean, or else null if body content doesn't exist. * * @param cls The type of bean to convert the response to * @return The response as a bean */ public T bean(Class cls) { + if (httpResponse == null) { + return null; + } final BodyContent body = context.readErrorContent(responseAsBytes, httpResponse); return context.readBean(cls, body); } /** - * Return the response body content as a UTF8 string. + * Return the response body content as a UTF8 string, or else null if body content doesn't exist. */ public String bodyAsString() { + if (httpResponse == null) { + return null; + } final BodyContent body = context.readErrorContent(responseAsBytes, httpResponse); return new String(body.content(), StandardCharsets.UTF_8); } - /** - * Return the response body content as raw bytes. + /** + * Return the response body content as raw bytes, or else null if body content doesn't exist. */ public byte[] bodyAsBytes() { + if (httpResponse == null) { + return null; + } final BodyContent body = context.readErrorContent(responseAsBytes, httpResponse); return body.content(); } diff --git a/client/src/main/java/io/avaje/http/client/JacksonBodyAdapter.java b/client/src/main/java/io/avaje/http/client/JacksonBodyAdapter.java index 1b0ab48..5aeacf6 100644 --- a/client/src/main/java/io/avaje/http/client/JacksonBodyAdapter.java +++ b/client/src/main/java/io/avaje/http/client/JacksonBodyAdapter.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.type.CollectionType; import java.io.IOException; +import java.io.UncheckedIOException; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -14,14 +15,14 @@ * *

{@code
  *
- *   HttpClientContext.newBuilder()
+ *   HttpClientContext.builder()
  *       .baseUrl(baseUrl)
  *       .bodyAdapter(new JacksonBodyAdapter())
  *       .build();
  *
  * }
*/ -public class JacksonBodyAdapter implements BodyAdapter { +public final class JacksonBodyAdapter implements BodyAdapter { private final ObjectMapper mapper; @@ -98,7 +99,7 @@ public T readBody(String content) { try { return reader.readValue(content); } catch (IOException e) { - throw new RuntimeException(e); + throw new UncheckedIOException(e); } } @@ -107,7 +108,7 @@ public T read(BodyContent bodyContent) { try { return reader.readValue(bodyContent.content()); } catch (IOException e) { - throw new RuntimeException(e); + throw new UncheckedIOException(e); } } } @@ -132,7 +133,7 @@ public BodyContent write(T bean) { try { return BodyContent.asJson(writer.writeValueAsBytes(bean)); } catch (JsonProcessingException e) { - throw new RuntimeException(e); + throw new UncheckedIOException(e); } } } diff --git a/client/src/main/java/io/avaje/http/client/JsonbBodyAdapter.java b/client/src/main/java/io/avaje/http/client/JsonbBodyAdapter.java index e04a0dc..610c877 100644 --- a/client/src/main/java/io/avaje/http/client/JsonbBodyAdapter.java +++ b/client/src/main/java/io/avaje/http/client/JsonbBodyAdapter.java @@ -1,29 +1,31 @@ package io.avaje.http.client; -import io.avaje.jsonb.JsonType; -import io.avaje.jsonb.Jsonb; - +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; import java.util.List; import java.util.concurrent.ConcurrentHashMap; +import io.avaje.jsonb.JsonType; +import io.avaje.jsonb.Jsonb; + /** - * avaje jsonb BodyAdapter to read and write beans as JSON. + * Avaje Jsonb BodyAdapter to read and write beans as JSON. * *
{@code
  *
- *   HttpClientContext.newBuilder()
+ *   HttpClientContext.builder()
  *       .baseUrl(baseUrl)
  *       .bodyAdapter(new JsonbBodyAdapter())
  *       .build();
  *
  * }
*/ -public class JsonbBodyAdapter implements BodyAdapter { +public final class JsonbBodyAdapter implements BodyAdapter { private final Jsonb jsonb; - private final ConcurrentHashMap, BodyWriter> beanWriterCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap, BodyReader> beanReaderCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap, BodyReader> listReaderCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> beanWriterCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> beanReaderCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> listReaderCache = new ConcurrentHashMap<>(); /** * Create passing the Jsonb to use. @@ -36,7 +38,7 @@ public JsonbBodyAdapter(Jsonb jsonb) { * Create with a default Jsonb that allows unknown properties. */ public JsonbBodyAdapter() { - this.jsonb = Jsonb.newBuilder().build(); + this.jsonb = Jsonb.builder().build(); } @SuppressWarnings("unchecked") @@ -51,6 +53,18 @@ public BodyReader beanReader(Class cls) { return (BodyReader) beanReaderCache.computeIfAbsent(cls, aClass -> new JReader<>(jsonb.type(cls))); } + @SuppressWarnings("unchecked") + @Override + public BodyReader beanReader(ParameterizedType cls) { + return (BodyReader) beanReaderCache.computeIfAbsent(cls, aClass -> new JReader<>(jsonb.type(cls))); + } + + @SuppressWarnings("unchecked") + @Override + public BodyReader> listReader(ParameterizedType cls) { + return (BodyReader>) listReaderCache.computeIfAbsent(cls, aClass -> new JReader<>(jsonb.type(cls).list())); + } + @SuppressWarnings("unchecked") @Override public BodyReader> listReader(Class cls) { diff --git a/client/src/main/java/io/avaje/http/client/PathConversion.java b/client/src/main/java/io/avaje/http/client/PathConversion.java index 03b4fcd..adffd27 100644 --- a/client/src/main/java/io/avaje/http/client/PathConversion.java +++ b/client/src/main/java/io/avaje/http/client/PathConversion.java @@ -3,7 +3,7 @@ /** * Helper methods to convert common types to String path values. */ -public class PathConversion { +public final class PathConversion { /** * Convert to path. diff --git a/client/src/main/java/io/avaje/http/client/RequestLogger.java b/client/src/main/java/io/avaje/http/client/RequestLogger.java index 48b8a53..391d04b 100644 --- a/client/src/main/java/io/avaje/http/client/RequestLogger.java +++ b/client/src/main/java/io/avaje/http/client/RequestLogger.java @@ -1,8 +1,8 @@ package io.avaje.http.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import io.avaje.applog.AppLog; +import java.lang.System.Logger.Level; import java.net.http.HttpHeaders; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -11,23 +11,24 @@ import java.util.Set; /** - * Logs request and response details for debug logging purposes. + * Logs request and response details for debug logging purposes using System.Logger. *

- * This implementation logs the request and response with the same - * single logging entry rather than separate logging of the request - * and response. + * This implementation logs the request and response with the same single logging entry + * rather than separate logging of the request and response. *

- * With logging level set to {@code DEBUG} for - * {@code io.avaje.http.client.RequestLogger} the request and response - * are logged as a summary with response status and time. + * With logging level set to {@code DEBUG} for {@code io.avaje.http.client.RequestLogger} the + * request and response are logged as a summary with response status and time. *

- * Set the logging level to {@code TRACE} to include the request - * and response headers and body payloads with truncation for large - * bodies. + * Set the logging level to {@code TRACE} to include the request and response headers and body + * payloads with truncation for large bodies. + *

+ * Using System.Logger, messages by default go to JUL (Java Util Logging) unless a provider + * is registered. We can use io.avaje:avaje-slf4j-jpl to have System.Logger + * messages go to slf4j-api. */ public class RequestLogger implements RequestListener { - private static final Logger log = LoggerFactory.getLogger(RequestLogger.class); + private static final System.Logger log = AppLog.getLogger("io.avaje.http.client.RequestLogger"); private final String delimiter; @@ -47,7 +48,7 @@ public RequestLogger(String delimiter) { @Override public void response(Event event) { - if (log.isDebugEnabled()) { + if (log.isLoggable(Level.DEBUG)) { final HttpResponse response = event.response(); final HttpRequest request = response.request(); long micros = event.responseTimeMicros(); @@ -58,13 +59,13 @@ public void response(Event event) { .append(" uri:").append(event.uri()) .append(" timeMicros:").append(micros); - if (log.isTraceEnabled()) { + if (log.isLoggable(Level.TRACE)) { headers(sb, "req-head: ", request.headers()); body(sb, "req-body: ", event.requestBody()); headers(sb, "res-head: ", response.headers()); body(sb, "res-body: ", event.responseBody()); } - log.debug(sb.toString()); + log.log(Level.DEBUG, sb.toString()); } } diff --git a/client/src/main/java/io/avaje/http/client/RetryHandler.java b/client/src/main/java/io/avaje/http/client/RetryHandler.java index 67ed83d..e9d16ba 100644 --- a/client/src/main/java/io/avaje/http/client/RetryHandler.java +++ b/client/src/main/java/io/avaje/http/client/RetryHandler.java @@ -11,9 +11,19 @@ public interface RetryHandler { * Return true if the request should be retried. * * @param retryCount The number of retry attempts already executed - * @param response The HTTP response + * @param response The HTTP response * @return True if the request should be retried or false if not */ boolean isRetry(int retryCount, HttpResponse response); + /** + * Return true if the request should be retried. + * + * @param retryCount The number of retry attempts already executed + * @param exception The Wrapped Error thrown by the underlying Http Client + * @return True if the request should be retried or false if not + */ + default boolean isExceptionRetry(int retryCount, HttpException exception) { + throw exception; + } } diff --git a/client/src/main/java/io/avaje/http/client/SimpleRetryHandler.java b/client/src/main/java/io/avaje/http/client/SimpleRetryHandler.java index 3b0ea55..a7575e3 100644 --- a/client/src/main/java/io/avaje/http/client/SimpleRetryHandler.java +++ b/client/src/main/java/io/avaje/http/client/SimpleRetryHandler.java @@ -1,29 +1,45 @@ package io.avaje.http.client; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import io.avaje.applog.AppLog; +import java.lang.System.Logger.Level; import java.net.http.HttpResponse; +import java.util.Random; /** * Simple retry with max attempts and linear backoff. */ public class SimpleRetryHandler implements RetryHandler { - private static final Logger log = LoggerFactory.getLogger(SimpleRetryHandler.class); + private static final System.Logger log = AppLog.getLogger("io.avaje.http.client"); private final int maxRetries; private final long backoffMillis; + private final int gitterMillis; + private final Random random; /** * Create with maximum number of retries and linear backoff time. * * @param maxRetries The maximum number of retry attempts * @param backoffMillis The linear backoff between attempts in milliseconds + * @param gitterMillis The maximum amount of gitter that gets added to backoffMillis */ - public SimpleRetryHandler(int maxRetries, long backoffMillis) { + public SimpleRetryHandler(int maxRetries, int backoffMillis, int gitterMillis) { this.maxRetries = maxRetries; this.backoffMillis = backoffMillis; + this.gitterMillis = gitterMillis; + this.random = new Random(); + } + + /** + * Create with maximum number of retries and linear backoff time and no gitter. + * + * @param maxRetries The maximum number of retry attempts + * @param backoffMillis The linear backoff between attempts in milliseconds + */ + public SimpleRetryHandler(int maxRetries, int backoffMillis) { + this(maxRetries, backoffMillis, 0); } @Override @@ -31,9 +47,12 @@ public boolean isRetry(int retryCount, HttpResponse response) { if (response.statusCode() < 500 || retryCount >= maxRetries) { return false; } - log.debug("retry count:{} status:{} uri:{}", retryCount, response.statusCode(), response.uri()); + if (log.isLoggable(Level.DEBUG)) { + log.log(Level.DEBUG, "retry count:{0} status:{1} uri:{2}", retryCount, response.statusCode(), response.uri()); + } try { - Thread.sleep(backoffMillis); + int gitter = gitterMillis < 1 ? 0 : random.nextInt(gitterMillis); + Thread.sleep(backoffMillis + gitter); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; diff --git a/client/src/main/java/io/avaje/http/client/SpiHttpClient.java b/client/src/main/java/io/avaje/http/client/SpiHttpClient.java new file mode 100644 index 0000000..b7a1c1f --- /dev/null +++ b/client/src/main/java/io/avaje/http/client/SpiHttpClient.java @@ -0,0 +1,53 @@ +package io.avaje.http.client; + +import java.net.http.HttpClient; +import java.net.http.HttpResponse; + +/** + * Internal Http Client interface. + */ +interface SpiHttpClient { + + /** + * Return a UrlBuilder to use to build an URL taking into + * account the base URL. + */ + UrlBuilder url(); + + /** + * Return the underlying http client. + */ + HttpClient httpClient(); + + /** + * Return the response content taking into account content encoding. + * + * @param httpResponse The HTTP response to decode the content from + * @return The decoded content + */ + BodyContent readContent(HttpResponse httpResponse); + + /** + * Decode the response content given the Content-Encoding http header. + * + * @param httpResponse The HTTP response + * @return The decoded content + */ + byte[] decodeContent(HttpResponse httpResponse); + + /** + * Decode the body using the given encoding. + * + * @param encoding The encoding used to decode the content + * @param content The raw content being decoded + * @return The decoded content + */ + byte[] decodeContent(String encoding, byte[] content); + + /** + * Check the response status code and throw HttpException if the status + * code is in the error range. + */ + void checkResponse(HttpResponse response); + +} diff --git a/client/src/main/java/io/avaje/http/client/UrlBuilder.java b/client/src/main/java/io/avaje/http/client/UrlBuilder.java index 55818ff..40e82d7 100644 --- a/client/src/main/java/io/avaje/http/client/UrlBuilder.java +++ b/client/src/main/java/io/avaje/http/client/UrlBuilder.java @@ -7,7 +7,7 @@ /** * Build a URL typically using a base url and adding path and query parameters. */ -public class UrlBuilder { +public final class UrlBuilder { private final StringBuilder buffer = new StringBuilder(100); diff --git a/client/src/main/java/io/avaje/http/client/package-info.java b/client/src/main/java/io/avaje/http/client/package-info.java index dd19ed7..979003e 100644 --- a/client/src/main/java/io/avaje/http/client/package-info.java +++ b/client/src/main/java/io/avaje/http/client/package-info.java @@ -6,7 +6,7 @@ * *

{@code
  *
- *   HttpClientContext ctx = HttpClientContext.newBuilder()
+ *   HttpClientContext ctx = HttpClientContext.builder()
  *       .baseUrl("http://localhost:8080")
  *       .bodyAdapter(new JacksonBodyAdapter())
  *       .build();
diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java
index 2f77bbc..56ad106 100644
--- a/client/src/main/java/module-info.java
+++ b/client/src/main/java/module-info.java
@@ -3,9 +3,12 @@
   uses io.avaje.http.client.HttpApiProvider;
 
   requires transitive java.net.http;
-  requires transitive org.slf4j;
+  requires transitive io.avaje.applog;
   requires static com.fasterxml.jackson.databind;
+  requires static com.fasterxml.jackson.annotation;
+  requires static com.fasterxml.jackson.core;
   requires static io.avaje.jsonb;
+  requires static io.avaje.inject;
 
   exports io.avaje.http.client;
 }
diff --git a/client/src/test/java/io/avaje/http/client/AsyncTest.java b/client/src/test/java/io/avaje/http/client/AsyncTest.java
new file mode 100644
index 0000000..2bc5a44
--- /dev/null
+++ b/client/src/test/java/io/avaje/http/client/AsyncTest.java
@@ -0,0 +1,38 @@
+package io.avaje.http.client;
+
+import org.junit.jupiter.api.Test;
+
+import java.net.http.HttpResponse;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class AsyncTest extends BaseWebTest {
+
+  final HttpClientContext clientContext = client();
+
+  @Test
+  void waitForAsync()  {
+    final CompletableFuture>> future = clientContext.request()
+      .path("hello").path("stream")
+      .GET()
+      .async()
+      .asLines();
+
+    final AtomicBoolean flag = new AtomicBoolean();
+    future.whenComplete((hres, throwable) -> {
+      flag.set(true);
+      assertThat(hres.statusCode()).isEqualTo(200);
+      List lines = hres.body().collect(Collectors.toList());
+      assertThat(lines).hasSize(4);
+      assertThat(lines.get(0)).contains("{\"id\":1, \"name\":\"one\"}");
+    }).join();
+
+    assertThat(flag).isTrue();
+  }
+
+}
diff --git a/client/src/test/java/io/avaje/http/client/AuthTokenTest.java b/client/src/test/java/io/avaje/http/client/AuthTokenTest.java
index d32969b..7a56786 100644
--- a/client/src/test/java/io/avaje/http/client/AuthTokenTest.java
+++ b/client/src/test/java/io/avaje/http/client/AuthTokenTest.java
@@ -45,7 +45,7 @@ public AuthToken obtainToken(HttpClientRequest tokenRequest) {
   @Test
   void sendEmail() {
 
-    HttpClientContext ctx = HttpClientContext.newBuilder()
+    HttpClientContext ctx = HttpClientContext.builder()
       .baseUrl("https://foo")
       .bodyAdapter(new JacksonBodyAdapter(objectMapper))
       .authTokenProvider(new MyAuthTokenProvider())
diff --git a/client/src/test/java/io/avaje/http/client/BaseWebTest.java b/client/src/test/java/io/avaje/http/client/BaseWebTest.java
index bb37e64..7b38ff6 100644
--- a/client/src/test/java/io/avaje/http/client/BaseWebTest.java
+++ b/client/src/test/java/io/avaje/http/client/BaseWebTest.java
@@ -6,6 +6,8 @@
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
 
+import java.time.Duration;
+
 public class BaseWebTest {
 
   static Javalin webServer;
@@ -24,8 +26,10 @@ public static void shutdown() {
   }
 
   public static HttpClientContext client() {
-    return HttpClientContext.newBuilder()
+    return HttpClientContext.builder()
       .baseUrl(baseUrl)
+      .connectionTimeout(Duration.ofSeconds(1))
+      .requestTimeout(Duration.ofSeconds(1))
       .bodyAdapter(new JacksonBodyAdapter(new ObjectMapper()))
       .build();
   }
diff --git a/client/src/test/java/io/avaje/http/client/BasicAuthInterceptTest.java b/client/src/test/java/io/avaje/http/client/BasicAuthInterceptTest.java
index 95147d2..fb89c1d 100644
--- a/client/src/test/java/io/avaje/http/client/BasicAuthInterceptTest.java
+++ b/client/src/test/java/io/avaje/http/client/BasicAuthInterceptTest.java
@@ -18,7 +18,7 @@ void encode() {
   void beforeRequest() {
     // setup
     final BasicAuthIntercept intercept = new BasicAuthIntercept("Aladdin", "open sesame");
-    final HttpClientContext ctx = HttpClientContext.newBuilder().baseUrl("junk").build();
+    final HttpClientContext ctx = HttpClientContext.builder().baseUrl("junk").build();
 
     // act
     final HttpClientRequest request = ctx.request();
diff --git a/client/src/test/java/io/avaje/http/client/DHttpApiTest.java b/client/src/test/java/io/avaje/http/client/DHttpApiTest.java
index 0e5e1ab..fb597fb 100644
--- a/client/src/test/java/io/avaje/http/client/DHttpApiTest.java
+++ b/client/src/test/java/io/avaje/http/client/DHttpApiTest.java
@@ -18,7 +18,7 @@ public class DHttpApiTest {
   @Test
   void test_github_listRepos() {
 
-    final HttpClientContext clientContext = HttpClientContext.newBuilder()
+    final HttpClientContext clientContext = HttpClientContext.builder()
       .baseUrl("https://api.github.com")
       .bodyAdapter(new JacksonBodyAdapter())
       .build();
@@ -36,9 +36,10 @@ void jsonb_github_listRepos() {
 
     Jsonb jsonb = Jsonb.newBuilder()
       .add(Repo.class, RepoJsonAdapter::new)
+      //.adapter(new JacksonAdapter())
       .build();
 
-    final HttpClientContext clientContext = HttpClientContext.newBuilder()
+    final HttpClientContext clientContext = HttpClientContext.builder()
       .baseUrl("https://api.github.com")
       .bodyAdapter(new JsonbBodyAdapter(jsonb))
       .build();
diff --git a/client/src/test/java/io/avaje/http/client/DHttpClientContextTest.java b/client/src/test/java/io/avaje/http/client/DHttpClientContextTest.java
index abd57b9..0811224 100644
--- a/client/src/test/java/io/avaje/http/client/DHttpClientContextTest.java
+++ b/client/src/test/java/io/avaje/http/client/DHttpClientContextTest.java
@@ -41,46 +41,48 @@ void gzip_contentDecode() {
   void build_basic() {
 
     final HttpClientContext context =
-      HttpClientContext.newBuilder()
+      HttpClientContext.builder()
       .baseUrl("http://localhost")
       .build();
 
+    SpiHttpClient spi = (SpiHttpClient)context;
     // has default client created
-    assertThat(context.httpClient()).isNotNull();
-    assertThat(context.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2);
-    assertThat(context.httpClient().cookieHandler()).isPresent();
+    assertThat(spi.httpClient()).isNotNull();
+    assertThat(spi.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2);
+    assertThat(spi.httpClient().cookieHandler()).isPresent();
 
     // has expected url building
-    assertThat(context.url().build()).isEqualTo("http://localhost");
-    assertThat(context.url().path("hello").build()).isEqualTo("http://localhost/hello");
-    assertThat(context.url().queryParam("hello","there").build()).isEqualTo("http://localhost?hello=there");
+    assertThat(spi.url().build()).isEqualTo("http://localhost");
+    assertThat(spi.url().path("hello").build()).isEqualTo("http://localhost/hello");
+    assertThat(spi.url().queryParam("hello","there").build()).isEqualTo("http://localhost?hello=there");
   }
 
   @Test
   void build_noCookieHandler() {
 
     final HttpClientContext context =
-      HttpClientContext.newBuilder()
+      HttpClientContext.builder()
         .baseUrl("http://localhost")
         .cookieHandler(null)
         .redirect(HttpClient.Redirect.ALWAYS)
         .build();
 
+    SpiHttpClient spi = (SpiHttpClient)context;
     // has default client created
-    assertThat(context.httpClient()).isNotNull();
-    assertThat(context.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2);
-    assertThat(context.httpClient().cookieHandler()).isEmpty();
+    assertThat(spi.httpClient()).isNotNull();
+    assertThat(spi.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2);
+    assertThat(spi.httpClient().cookieHandler()).isEmpty();
 
     // has expected url building
-    assertThat(context.url().build()).isEqualTo("http://localhost");
-    assertThat(context.url().path("hello").build()).isEqualTo("http://localhost/hello");
-    assertThat(context.url().queryParam("hello","there").build()).isEqualTo("http://localhost?hello=there");
+    assertThat(spi.url().build()).isEqualTo("http://localhost");
+    assertThat(spi.url().path("hello").build()).isEqualTo("http://localhost/hello");
+    assertThat(spi.url().queryParam("hello","there").build()).isEqualTo("http://localhost?hello=there");
   }
 
   @Test
   void build_missingBaseUrl() {
     try {
-      HttpClientContext.newBuilder().build();
+      HttpClientContext.builder().build();
     } catch (NullPointerException e) {
       assertThat(e.getMessage()).contains("baseUrl is not specified");
     }
diff --git a/client/src/test/java/io/avaje/http/client/DHttpClientRequestTest.java b/client/src/test/java/io/avaje/http/client/DHttpClientRequestTest.java
index 3c92f16..f771785 100644
--- a/client/src/test/java/io/avaje/http/client/DHttpClientRequestTest.java
+++ b/client/src/test/java/io/avaje/http/client/DHttpClientRequestTest.java
@@ -5,14 +5,14 @@
 import java.time.Duration;
 
 import static org.assertj.core.api.Assertions.assertThat;
-import static org.mockito.Mockito.mock;
 
 class DHttpClientRequestTest {
 
+  final DHttpClientContext context = new DHttpClientContext(null, null, null, null, null, null, null, null);
+
   @Test
   void suppressLogging_listenerEvent_expect_suppressedPayloadContent() {
-
-    final DHttpClientRequest request = new DHttpClientRequest(mock(DHttpClientContext.class), Duration.ZERO);
+    final DHttpClientRequest request = new DHttpClientRequest(context, Duration.ZERO);
 
     request.suppressLogging();
     final RequestListener.Event event = request.listenerEvent();
@@ -23,8 +23,7 @@ void suppressLogging_listenerEvent_expect_suppressedPayloadContent() {
 
   @Test
   void skipAuthToken_listenerEvent_expect_suppressedPayloadContent() {
-
-    final DHttpClientRequest request = new DHttpClientRequest(mock(DHttpClientContext.class), Duration.ZERO);
+    final DHttpClientRequest request = new DHttpClientRequest(context, Duration.ZERO);
     assertThat(request.isSkipAuthToken()).isFalse();
 
     request.skipAuthToken();
diff --git a/client/src/test/java/io/avaje/http/client/HelloBasicAuthTest.java b/client/src/test/java/io/avaje/http/client/HelloBasicAuthTest.java
index a56332c..810a1c9 100644
--- a/client/src/test/java/io/avaje/http/client/HelloBasicAuthTest.java
+++ b/client/src/test/java/io/avaje/http/client/HelloBasicAuthTest.java
@@ -12,7 +12,7 @@ class HelloBasicAuthTest extends BaseWebTest {
   final HttpClientContext clientContext = client();
 
   public static HttpClientContext client() {
-    return HttpClientContext.newBuilder()
+    return HttpClientContext.builder()
       .baseUrl(baseUrl)
       .bodyAdapter(new JacksonBodyAdapter(new ObjectMapper()))
       .requestIntercept(new BasicAuthIntercept("rob", "bot"))
diff --git a/client/src/test/java/io/avaje/http/client/HelloControllerTest.java b/client/src/test/java/io/avaje/http/client/HelloControllerTest.java
index 6c594bd..c2b1aab 100644
--- a/client/src/test/java/io/avaje/http/client/HelloControllerTest.java
+++ b/client/src/test/java/io/avaje/http/client/HelloControllerTest.java
@@ -1,11 +1,11 @@
 package io.avaje.http.client;
 
+import com.fasterxml.jackson.databind.ObjectMapper;
 import org.example.webserver.ErrorResponse;
 import org.example.webserver.HelloDto;
 import org.junit.jupiter.api.Test;
 
 import java.io.*;
-import java.net.http.HttpClient;
 import java.net.http.HttpResponse;
 import java.nio.charset.StandardCharsets;
 import java.nio.file.Path;
@@ -22,15 +22,49 @@
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
+import static java.net.http.HttpClient.Version.HTTP_1_1;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.*;
 
 class HelloControllerTest extends BaseWebTest {
 
+  private static final ObjectMapper objectMapper = new ObjectMapper();
+
   final HttpClientContext clientContext = client();
 
+  @Test
+  void newClientTest() {
+    HttpClient client = HttpClient.builder()
+      .baseUrl("http://localhost:8887")
+      .connectionTimeout(Duration.ofSeconds(1))
+      .bodyAdapter(new JacksonBodyAdapter())
+      .build();
+
+    client.metrics(true);
+    Map params = new LinkedHashMap<>();
+    params.put("A", "a");
+    params.put("B", "b");
+
+    final HttpResponse hres = client.request()
+      .path("hello").path("message")
+      .queryParam(params)
+      .GET().asString();
+
+    assertThat(hres.statusCode()).isEqualTo(200);
+    assertThat(hres.uri().toString()).isEqualTo("http://localhost:8887/hello/message?A=a&B=b");
+
+    HttpClient.Metrics metrics = client.metrics();
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(0);
+    assertThat(metrics.responseBytes()).isGreaterThan(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
+    assertThat(metrics.maxMicros()).isEqualTo(metrics.totalMicros());
+    assertThat(metrics.avgMicros()).isEqualTo(metrics.totalMicros());
+  }
+
   @Test
   void queryParamMap() {
+    clientContext.metrics(true);
     Map params = new LinkedHashMap<>();
     params.put("A", "a");
     params.put("B", "b");
@@ -42,6 +76,14 @@ void queryParamMap() {
 
     assertThat(hres.statusCode()).isEqualTo(200);
     assertThat(hres.uri().toString()).isEqualTo("http://localhost:8887/hello/message?A=a&B=b");
+
+    HttpClientContext.Metrics metrics = clientContext.metrics();
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(0);
+    assertThat(metrics.responseBytes()).isGreaterThan(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
+    assertThat(metrics.maxMicros()).isEqualTo(metrics.totalMicros());
+    assertThat(metrics.avgMicros()).isEqualTo(metrics.totalMicros());
   }
 
   @Test
@@ -61,6 +103,7 @@ void asLines() {
 
   @Test
   void asLines_async() throws ExecutionException, InterruptedException {
+    clientContext.metrics(true);
     final CompletableFuture>> future = clientContext.request()
       .path("hello").path("stream")
       .GET()
@@ -73,6 +116,11 @@ void asLines_async() throws ExecutionException, InterruptedException {
 
     assertThat(lines).hasSize(4);
     assertThat(lines.get(0)).contains("{\"id\":1, \"name\":\"one\"}");
+    HttpClientContext.Metrics metrics = clientContext.metrics();
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(0);
+    assertThat(metrics.responseBytes()).isEqualTo(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
   }
 
   @Test
@@ -206,6 +254,7 @@ void get_stream() {
 
   @Test
   void get_stream_NotFoundException() {
+    clientContext.metrics(true);
     final HttpException httpException = assertThrows(HttpException.class, () ->
       clientContext.request()
         .path("this-path-does-not-exist")
@@ -214,6 +263,11 @@ void get_stream_NotFoundException() {
 
     assertThat(httpException.statusCode()).isEqualTo(404);
     assertThat(httpException.httpResponse().statusCode()).isEqualTo(404);
+    HttpClientContext.Metrics metrics = clientContext.metrics(true);
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(1);
+    assertThat(metrics.responseBytes()).isEqualTo(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
   }
 
   @Test
@@ -286,7 +340,7 @@ void async_stream_fromLineSubscriber() throws ExecutionException, InterruptedExc
     final CompletableFuture> future = clientContext.request()
       .path("hello/stream")
       .GET()
-      .async().withHandler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() {
+      .async().handler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() {
         @Override
         public void onSubscribe(Flow.Subscription subscription) {
           subscription.request(Long.MAX_VALUE);
@@ -360,6 +414,7 @@ void asByteArray_async() throws ExecutionException, InterruptedException {
 
   @Test
   void get_notFound() {
+    clientContext.metrics(true);
     UUID nullUUID = null;
     final HttpClientRequest request = clientContext.request()
       .path("hello").path(UUID.randomUUID()).queryParam("zone", ZoneId.of("UTC"))
@@ -372,7 +427,12 @@ void get_notFound() {
     final HttpResponse hres = request.GET().asString();
 
     assertThat(hres.statusCode()).isEqualTo(404);
-    assertThat(hres.body()).contains("Not found");
+    assertThat(hres.body()).contains("Not Found");
+    HttpClientContext.Metrics metrics = clientContext.metrics(true);
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(1);
+    assertThat(metrics.responseBytes()).isGreaterThan(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
   }
 
   @Test
@@ -518,10 +578,10 @@ void callBytesAsync() throws ExecutionException, InterruptedException {
   }
 
   @Test
-  void callWithHandler() {
+  void callHandler() {
     final HttpResponse hres = clientContext.request()
       .path("hello").path("message")
-      .GET().call().withHandler(HttpResponse.BodyHandlers.ofString())
+      .GET().call().handler(HttpResponse.BodyHandlers.ofString())
       .execute();
 
     assertThat(hres.body()).contains("hello world");
@@ -532,7 +592,7 @@ void callWithHandler() {
   void callWithHandlerAsync() throws ExecutionException, InterruptedException {
     final HttpResponse hres = clientContext.request()
       .path("hello").path("message")
-      .GET().call().withHandler(HttpResponse.BodyHandlers.ofString())
+      .GET().call().handler(HttpResponse.BodyHandlers.ofString())
       .async().get();
 
     assertThat(hres.body()).contains("hello world");
@@ -732,7 +792,7 @@ void async_whenComplete_returningBean() throws ExecutionException, InterruptedEx
 
   @Test
   void async_whenComplete_throwingHttpException() {
-
+    clientContext.metrics(true);
     AtomicReference causeRef = new AtomicReference<>();
 
     final CompletableFuture future = clientContext.request()
@@ -766,6 +826,11 @@ void async_whenComplete_throwingHttpException() {
     } catch (CompletionException e) {
       assertThat(e.getCause()).isSameAs(causeRef.get());
     }
+    HttpClientContext.Metrics metrics = clientContext.metrics(true);
+    assertThat(metrics.totalCount()).isEqualTo(1);
+    assertThat(metrics.errorCount()).isEqualTo(1);
+    assertThat(metrics.responseBytes()).isGreaterThan(0);
+    assertThat(metrics.totalMicros()).isGreaterThan(0);
   }
 
   @Test
@@ -913,7 +978,7 @@ void postForm_asVoid_validResponse() {
     assertThat(res.request()).isNotNull();
     assertThat(res.previousResponse()).isEmpty();
     assertThat(res.sslSession()).isEmpty();
-    assertThat(res.version()).isEqualTo(HttpClient.Version.HTTP_1_1);
+    assertThat(res.version()).isEqualTo(HTTP_1_1);
     assertThat(res.uri().toString()).isEqualTo("http://localhost:8887/hello/saveform");
   }
 
diff --git a/client/src/test/java/io/avaje/http/client/RequestListenerTest.java b/client/src/test/java/io/avaje/http/client/RequestListenerTest.java
index 04773e1..8788c58 100644
--- a/client/src/test/java/io/avaje/http/client/RequestListenerTest.java
+++ b/client/src/test/java/io/avaje/http/client/RequestListenerTest.java
@@ -1,6 +1,5 @@
 package io.avaje.http.client;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 
 import java.net.http.HttpResponse;
@@ -34,7 +33,7 @@ public void response(Event event) {
   }
 
   private HttpClientContext createClient(TDRequestListener tdRequestListener) {
-    return HttpClientContext.newBuilder()
+    return HttpClientContext.builder()
       .baseUrl(baseUrl)
       .requestLogging(false)
       .requestListener(new RequestLogger())
diff --git a/client/src/test/java/io/avaje/http/client/RetryTest.java b/client/src/test/java/io/avaje/http/client/RetryTest.java
index 7454160..82c5fe6 100644
--- a/client/src/test/java/io/avaje/http/client/RetryTest.java
+++ b/client/src/test/java/io/avaje/http/client/RetryTest.java
@@ -1,28 +1,38 @@
 package io.avaje.http.client;
 
-import com.fasterxml.jackson.databind.ObjectMapper;
 import org.junit.jupiter.api.Test;
 
 import java.net.http.HttpResponse;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
-public class RetryTest extends BaseWebTest {
+class RetryTest extends BaseWebTest {
 
-  final MyIntercept myIntercept = new MyIntercept();
-  final HttpClientContext clientContext = initClientWithRetry();
 
-  HttpClientContext initClientWithRetry() {
-    return HttpClientContext.newBuilder()
+  HttpClientContext initClientWithRetry(MyIntercept myIntercept, RetryHandler retryHandler) {
+    return HttpClientContext.builder()
       .baseUrl("http://localhost:8887")
       .bodyAdapter(new JacksonBodyAdapter())
-      .retryHandler(new SimpleRetryHandler(4, 1))
+      .retryHandler(retryHandler)
       .requestIntercept(myIntercept)
       .build();
   }
 
   @Test
   void retryTest() {
+    final MyIntercept myIntercept = new MyIntercept();
+    final HttpClientContext clientContext = initClientWithRetry(myIntercept, new SimpleRetryHandler(4, 1));
+    performGetRequestAndAssert(myIntercept, clientContext);
+  }
+
+  @Test
+  void retryWithGitterTest() {
+    final MyIntercept myIntercept = new MyIntercept();
+    final HttpClientContext clientContext = initClientWithRetry(myIntercept, new SimpleRetryHandler(4, 10, 20));
+    performGetRequestAndAssert(myIntercept, clientContext);
+  }
+
+  private void performGetRequestAndAssert(MyIntercept myIntercept, HttpClientContext clientContext) {
     HttpResponse res = clientContext.request()
       .label("http_client_hello_retry")
       .path("hello/retry")
diff --git a/client/src/test/java/org/example/dinject/ConfigureWithDITest.java b/client/src/test/java/org/example/dinject/ConfigureWithDITest.java
new file mode 100644
index 0000000..74f1496
--- /dev/null
+++ b/client/src/test/java/org/example/dinject/ConfigureWithDITest.java
@@ -0,0 +1,35 @@
+package org.example.dinject;
+
+import io.avaje.http.client.BodyAdapter;
+import io.avaje.http.client.HttpClientContext;
+import io.avaje.http.client.JsonbBodyAdapter;
+import io.avaje.inject.BeanScope;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class ConfigureWithDITest {
+
+  @Test
+  void configureWith() {
+    try (BeanScope beanScope = BeanScope.builder().build()) {
+
+      assertThat(beanScope.contains("io.avaje.jsonb.Jsonb")).isTrue();
+
+      HttpClientContext.Builder builder = HttpClientContext.builder();
+      HttpClientContext.Builder.State state = builder.state();
+      assertThat(state.baseUrl()).isNull();
+      assertThat(state.bodyAdapter()).isNull();
+      assertThat(state.client()).isNull();
+      assertThat(state.requestLogging()).isTrue();
+      assertThat(state.requestTimeout()).isEqualByComparingTo(Duration.ofSeconds(20));
+      assertThat(state.retryHandler()).isNull();
+
+      builder.configureWith(beanScope);
+      BodyAdapter bodyAdapter = state.bodyAdapter();
+      assertThat(bodyAdapter).isInstanceOf(JsonbBodyAdapter.class);
+    }
+  }
+}
diff --git a/client/src/test/java/org/example/dinject/MyDIConfig.java b/client/src/test/java/org/example/dinject/MyDIConfig.java
new file mode 100644
index 0000000..35d27dc
--- /dev/null
+++ b/client/src/test/java/org/example/dinject/MyDIConfig.java
@@ -0,0 +1,15 @@
+package org.example.dinject;
+
+import io.avaje.inject.Bean;
+import io.avaje.inject.Factory;
+import io.avaje.jsonb.Jsonb;
+
+@Factory
+class MyDIConfig {
+
+  @Bean
+  Jsonb jsonb() {
+    return  Jsonb.builder().build();
+  }
+
+}
diff --git a/client/src/test/java/org/example/github/GithubTest.java b/client/src/test/java/org/example/github/GithubTest.java
index c464e1e..3a4c6c1 100644
--- a/client/src/test/java/org/example/github/GithubTest.java
+++ b/client/src/test/java/org/example/github/GithubTest.java
@@ -13,32 +13,29 @@ public class GithubTest {
 
   @Test
   @Disabled
-  void test() throws InterruptedException {
+  void test() {
 
-    final HttpClientContext clientContext = HttpClientContext.newBuilder()
+    final HttpClientContext clientContext = HttpClientContext.builder()
       .baseUrl("https://api.github.com")
       .bodyAdapter(new JacksonBodyAdapter())
       .requestLogging(false)
       .build();
 
+    // will not work under module classpath without registering the HttpApiProvider
+    final Simple simple = clientContext.create(Simple.class);
+
+    final List repos = simple.listRepos("rbygrave", "junk");
+    assertThat(repos).isNotEmpty();
+
     clientContext.request()
       .path("users").path("rbygrave").path("repos")
       .GET()
       .async()
       .asString()
       .thenAccept(res -> {
-
-        System.out.println("RES: "+res.statusCode());
-        System.out.println("BODY: "+res.body());
-      });
-
-    Thread.sleep(1_000);
-
-    // will not work under module classpath without registering the HttpApiProvider
-    final Simple simple = clientContext.create(Simple.class);
-
-    final List repos = simple.listRepos("rbygrave", "junk");
-    assertThat(repos).isNotEmpty();
+        System.out.println("RES: " + res.statusCode());
+        System.out.println("BODY: " + res.body().substring(0, 150) + "...");
+      }).join();
   }
 
 }
diff --git a/client/src/test/java/org/example/github/httpclient/Simple$HttpClient.java b/client/src/test/java/org/example/github/httpclient/Simple$HttpClient.java
index 5dbf79b..73a16d0 100644
--- a/client/src/test/java/org/example/github/httpclient/Simple$HttpClient.java
+++ b/client/src/test/java/org/example/github/httpclient/Simple$HttpClient.java
@@ -56,9 +56,9 @@ public InputStream getById2(String id, InputStream is) {
       context.request()
         .path("users").path(id).path("stream")
         .body(() -> is)
-        .GET().withHandler(HttpResponse.BodyHandlers.ofInputStream());
+        .GET().handler(HttpResponse.BodyHandlers.ofInputStream());
 
-    context.checkResponse(response);
+    //context.checkResponse(response);
     return response.body();
   }
 
diff --git a/gson-adapter/pom.xml b/gson-adapter/pom.xml
index e8fc8d9..c487768 100644
--- a/gson-adapter/pom.xml
+++ b/gson-adapter/pom.xml
@@ -2,14 +2,15 @@
 
   4.0.0
   
-    java11-oss
     org.avaje
-    3.2
+    java11-oss
+    3.9
+    
   
 
   io.avaje
   avaje-http-client-gson
-  1.13-SNAPSHOT
+  1.23-SNAPSHOT
 
   
     scm:git:git@github.com:avaje/avaje-http-client.git
@@ -21,13 +22,13 @@
     
       com.google.code.gson
       gson
-      2.8.9
+      2.10
     
 
     
       io.avaje
       avaje-http-client
-      1.12
+      1.23-SNAPSHOT
       provided
     
 
@@ -36,7 +37,7 @@
     
       io.avaje
       junit
-      1.0
+      1.1
       test
     
 
diff --git a/gson-adapter/src/main/java/io/avaje/http/client/gson/GsonBodyAdapter.java b/gson-adapter/src/main/java/io/avaje/http/client/gson/GsonBodyAdapter.java
index 238dc37..c45fc4a 100644
--- a/gson-adapter/src/main/java/io/avaje/http/client/gson/GsonBodyAdapter.java
+++ b/gson-adapter/src/main/java/io/avaje/http/client/gson/GsonBodyAdapter.java
@@ -10,11 +10,7 @@
 import io.avaje.http.client.BodyReader;
 import io.avaje.http.client.BodyWriter;
 
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.OutputStreamWriter;
+import java.io.*;
 import java.util.List;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -25,11 +21,11 @@
  *
  * 
{@code
  *
- *   HttpClientContext.newBuilder()
- *       .withBaseUrl(baseUrl)
- *       .withRequestListener(new RequestLogger())
- *       //.withBodyAdapter(new JacksonBodyAdapter(new ObjectMapper()))
- *       .withBodyAdapter(new GsonBodyAdapter(new Gson()))
+ *   HttpClientContext.builder()
+ *       .baseUrl(baseUrl)
+ *       .requestListener(new RequestLogger())
+ *       //.bodyAdapter(new JacksonBodyAdapter(new ObjectMapper()))
+ *       .bodyAdapter(new GsonBodyAdapter(new Gson()))
  *       .build();
  *
  * }
@@ -38,10 +34,8 @@ public class GsonBodyAdapter implements BodyAdapter { private final Gson gson; - private final ConcurrentHashMap, BodyWriter> beanWriterCache = new ConcurrentHashMap<>(); - + private final ConcurrentHashMap, BodyWriter> beanWriterCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap, BodyReader> beanReaderCache = new ConcurrentHashMap<>(); - private final ConcurrentHashMap, BodyReader> listReaderCache = new ConcurrentHashMap<>(); /** @@ -51,11 +45,12 @@ public GsonBodyAdapter(Gson gson) { this.gson = gson; } + @SuppressWarnings({"unchecked", "rawtypes"}) @Override - public BodyWriter beanWriter(Class cls) { - return beanWriterCache.computeIfAbsent(cls, aClass -> { + public BodyWriter beanWriter(Class cls) { + return (BodyWriter) beanWriterCache.computeIfAbsent(cls, aClass -> { try { - final TypeAdapter adapter = gson.getAdapter(cls); + final TypeAdapter adapter = gson.getAdapter(cls); return new Writer(gson, adapter); } catch (Exception e) { throw new RuntimeException(e); @@ -76,14 +71,14 @@ public BodyReader beanReader(Class cls) { }); } - @SuppressWarnings("unchecked") + @SuppressWarnings({"unchecked", "rawtypes"}) @Override public BodyReader> listReader(Class cls) { return (BodyReader>) listReaderCache.computeIfAbsent(cls, aClass -> { try { - final TypeToken listType = TypeToken.getParameterized(List.class, cls); - final TypeAdapter adapter = gson.getAdapter(listType); - return new Reader(gson, adapter); + final TypeToken listType = TypeToken.getParameterized(List.class, cls); + final TypeAdapter> adapter = gson.getAdapter(listType); + return new Reader<>(gson, adapter); } catch (Exception e) { throw new RuntimeException(e); } @@ -114,8 +109,7 @@ public T readBody(String content) { @Override public T read(BodyContent body) { - try { - InputStreamReader reader = new InputStreamReader(new ByteArrayInputStream(body.content())); + try (InputStreamReader reader = new InputStreamReader(new ByteArrayInputStream(body.content()))) { final JsonReader jsonReader = gson.newJsonReader(reader); return adapter.read(jsonReader); } catch (IOException e) { @@ -124,24 +118,23 @@ public T read(BodyContent body) { } } - @SuppressWarnings({"rawtypes"}) - private static class Writer implements BodyWriter { + private static class Writer implements BodyWriter { private final Gson gson; - private final TypeAdapter adapter; + private final TypeAdapter adapter; - Writer(Gson gson, TypeAdapter adapter) { + Writer(Gson gson, TypeAdapter adapter) { this.gson = gson; this.adapter = adapter; } @Override - public BodyContent write(Object bean, String contentType) { + public BodyContent write(T bean, String contentType) { return write(bean); } @Override - public BodyContent write(Object bean) { + public BodyContent write(T bean) { try { ByteArrayOutputStream os = new ByteArrayOutputStream(200); JsonWriter jsonWriter = gson.newJsonWriter(new OutputStreamWriter(os, UTF_8)); diff --git a/pom.xml b/pom.xml index 79d059d..3d204e5 100644 --- a/pom.xml +++ b/pom.xml @@ -2,9 +2,9 @@ 4.0.0 - java11-oss org.avaje - 3.2 + java11-oss + 3.8 io.avaje diff --git a/test/pom.xml b/test/pom.xml index 5548d43..4f16915 100644 --- a/test/pom.xml +++ b/test/pom.xml @@ -2,12 +2,12 @@ + 4.0.0 avaje-http-client-parent io.avaje 1 - 4.0.0 test @@ -16,38 +16,38 @@ io.avaje avaje-http-client - 1.13-SNAPSHOT + 1.23-SNAPSHOT io.avaje avaje-http-client-gson - 1.13-SNAPSHOT + 1.23-SNAPSHOT io.avaje avaje-http-api - 1.12 + 1.20 com.fasterxml.jackson.core jackson-databind - 2.12.5 + 2.14.1 io.avaje junit - 1.0 + 1.1 test - org.avaje.composite + org.avaje logback - 1.1 + 1.0 diff --git a/test/src/test/java/example/github/GithubTest.java b/test/src/test/java/example/github/GithubTest.java index 8e91264..9d6c76f 100644 --- a/test/src/test/java/example/github/GithubTest.java +++ b/test/src/test/java/example/github/GithubTest.java @@ -24,7 +24,7 @@ void test_with_gson() { } private void assertListRepos(BodyAdapter bodyAdapter) { - final HttpClientContext clientContext = HttpClientContext.newBuilder() + final HttpClientContext clientContext = HttpClientContext.builder() .baseUrl("https://api.github.com") .bodyAdapter(bodyAdapter) // .requestLogging(false)