Avaje Http Client

Discord Source API Docs Issues Releases
Discord Github Javadoc Github


A lightweight wrapper to the JDK 11+ Java Http Client.

JDK HttpClient Introduction

Some introductions to the JDK HttpClient:

Client API generation

We can define a java interface with annotations and have a Java annotation processor generate the client implementation (similar to Retrofit, Feign and JAX-RS client).

This client API generation targets avaje-http-client / JDK HttpClient only and uses source code generation via annotation processing so there is no use of reflection/dynamic proxies to slow things down.

Example client API
@Client
interface GitHub {

  @Get("/repos/{owner}/{repo}/contributors")
  List<Contributor> contributors(String owner, String repo);
}
Using client API
HttpClient client = HttpClient.builder()
  .baseUrl("https://api.github.com")
  //.bodyAdapter(new JsonbBodyAdapter())
  .build();

// obtain API implementation
GitHub github = client.create(GitHub.class);

try {
  // use the api/interface
  List<Contributor> contributors = github.contributors("rbygrave", "junk");

} catch (HttpException ex) {

  if (ex.getCause() != null) {
    // some IO/Interrupted exception happened while sending request.
  } else {
    //Use HttpException utility methods to access response status and handle/deserialize error body
    }
  }
}
API allowing async or sync use via HttpCall
@Client
interface GitHub {

  @Get("/repos/{owner}/{repo}/contributors")
  HttpCall<Contributor> contributors(String owner, String repo);
}

Getting started

Dependencies

Maven

Add avaje-http-client as a dependency.

<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-http-client</artifactId>
  <version>${avaje.http.client.version}</version>
</dependency>

Client API generation

For the client API generation, we need to add avaje-http-api as a dependency.

<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-http-api</artifactId>
  <version>${avaje.http.api.version}</version>
</dependency>

Client API - Annotation processor

Add the avaje-http-client-generator annotation processor.

<!-- Annotation processors -->
<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-http-client-generator</artifactId>
  <version>${avaje.http.api.version}</version>
  <scope>provided</scope>
</dependency>

Gradle

Add dependencies for avaje-http-client and jackson-databind.

dependencies {

  implementation 'com.fasterxml.jackson.core:jackson-databind:2.14.2'
  implementation 'io.avaje:avaje-http-client:1.35'
  ...
}

When using Client API we additionally need avaje-http-api and the avaje-http-client-generator annotation processor.

dependencies {
    implementation 'io.avaje:avaje-http-client:1.35'

    implementation 'io.avaje:avaje-http-api:1.17'
    annotationProcessor 'io.avaje:avaje-http-client-generator:1.35'
    ...
}

Limitations

Current notable limitations are:

Quick Overview

HttpClient

Create an HttpClient with a base URL and BodyAdapter (plus other options as necessary like retry, interceptors etc). It can be used to create requests. Requests can be executed synchronously or asynchronously with CompletableFuture.

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  .bodyAdapter(new JsonbBodyAdapter())
  //.bodyAdapter(new JacksonBodyAdapter())
  //.bodyAdapter(new GsonBodyAdapter(new Gson()))
  .build();

Example GET

Customer customer = client.request()
  .path("customers/42")
  .GET()
  .bean(Customer.class);

List<Product> products = client.request()
  .path("products")
  .GET()
  .list(Product.class);

HttpResponse<String> hres = client.request()
  .path("health")
  .GET()
  .asString();

Example GET Async

client.request()
 .path("hello")
 .GET()
 .async().asString()  // CompletableFuture<HttpResponse<String>>
 .whenComplete((hres, throwable) -> {

   if (throwable != null) {
     // CompletionException
     ...
   } else {
     // HttpResponse<String>
     int statusCode = hres.statusCode();
     String body = hres.body();
     ...
   }
 });

Sync and Async

We can make requests in simple blocking manner or via async() which uses CompletableFuture.

The JDK HttpClient provides a number of BodyHandlers including reactive Flow based subscribers. When executing the requests in asynchronous style the responses are processed in a reactive way. Generally CompletableFuture.whenComplete() will be called only when the response is ready.

Using withHandler(...) we can use any of the existing handlers or make our own HttpResponse.BodyHandler implementation.

Loom vs Async

With Loom we will be able to use simple blocking requests and get a similar performance as the async() requests. This is an important factor to keep in mind. To use loom, simply add a virtual thread executor to the http client builder.

See 10K requests - Loom vs Async

HttpClient

HttpClient is effectively a wrapper of java.net.http.HttpClient with extra features for request interception, body adapters, retry, etc.

Create a HttpClient with a base URL and a BodyAdapter (e.g. JsonbBodyAdapter). Set other options like retry and request interceptors as necessary.

Reference HttpClient.Builder

Creating HttpClient

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  .bodyAdapter(new JsonbBodyAdapter())
  // .bodyAdapter(new JacksonBodyAdapter())
  // .bodyAdapter(new GsonBodyAdapter(new Gson()))
  .build();

From the HttpClient instance we create requests and execute them.

Create and execute requests

HttpResponse<String> hres = client.request()
  .path("hello")
  .GET()
  .asString();

Json - BodyAdapter

avaje-http-client provides a BodyAdapter interface with implementations for Jackson, Gson and Avaje-Jsonb to adapt request and response body content from JSON to java types and from java types to JSON.

Using Avaje-Jsonb

To use avaje-jsonb for serialization of request and response body content to and from JSON we need to:

1. Add avaje-jsonb dependency

<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-jsonb</artifactId>
  <version>${avaje.jsonb.version}</version>
</dependency>

2. Register JsonbBodyAdapter

Register the JsonbBodyAdapter with the HttpClient builder. We can supply a jsonb instance to JsonbBodyAdapter or just use defaults. For example:

Jsonb jsonb = Jsonb.builder().build();

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  .bodyAdapter(new JsonbBodyAdapter(jsonb))
  .build();

Avaje-Jsonb uses APT source code generation to generate adapters for types annotated with @Json. This makes it a fast and light dependency, especially good for http clients, CLI applications and use with GraalVM native images.

Using Jackson

To use Jackson for serialization of request/response body content to/from JSON we need to:

1. Add jackson-databind dependency

<dependency>
  <groupId>com.fasterxml.jackson.core</groupId>
  <artifactId>jackson-databind</artifactId>
  <version>2.14.2</version>
</dependency>

2. Register JacksonBodyAdapter

Register a JacksonBodyAdapter with the HttpClient builder. We cab provide JacksonBodyAdapter an ObjectMapper that has modules and configuration. For example:

ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  .bodyAdapter(new JacksonBodyAdapter(objectMapper))
  .build();

Using Gson

To use Gson for serialization of request/response body content to/from JSON we need to:

1. Add gson dependency

<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.8.9</version>
</dependency>

2. Register GsonBodyAdapter

Register the GsonBodyAdapter with the HttpClient builder. For example:

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  .bodyAdapter(new GsonBodyAdapter(new Gson()))
  .build();

Request body

Instances of java.net.http.HttpRequest.BodyPublisher are used to send request body content. The JDK HttpClient comes with a number of implementations for different situations. You can use your own implementation as well if you so desire.

avaje-http-client adds URL encoded form and body adapters using Jackson and Gson to convert body content to/from JSON and java beans/types.

Request Body

Object as JSON

When we create the HttpClient, we can register a message body handler. When these are registered, we can call body(object) to write the body content as JSON.

Example: POST object as json

Hello bean = new Hello(42, "rob", "hello world");

HttpResponse<Void> res = client.request()
  .path("hello")
  .body(bean)
  .POST()
  .asVoid();

formParam

Calling formParam() adds URL encoded name-value pairs, and send body content as application/x-www-form-urlencoded.

Example: POST using formParams

HttpResponse<Void> res = client.request()
  .path("register/user")
  .formParam("name", "Bazz")
  .formParam("email", "user@foo.com")
  .formParam("url", "http://foo.com")
  .formParam("startDate", "2020-12-03")
  .POST()
  .asVoid();

Path (file)

This uses HttpRequest.BodyPublishers.ofFile(file) to stream file content as the request body.

Example: PUT file Path

Path file = ...;
HttpResponse<String> res = client.request()
  .path("upload")
  .body(file)
  .PUT()
  .asString();

InputStream

This uses HttpRequest.BodyPublishers.ofInputStream(supplier) to stream the request body content from the InputStream.

Example: PUT InputStream

InputStream inputStream = ...;

HttpResponse<String> res = client.request()
  .path("upload")
  .body(inputStream)
  .PUT()
  .asString();

HttpRequest.BodyPublisher

We can pass any HttpRequest.BodyPublisher into body(). Refer to HttpRequest.BodyPublishers for ones that come with JDK HttpClient.

Example: Use any BodyPublisher

HttpResponse<Void> res = client.request()
  .path("whazzzup")
  .body(HttpRequest.BodyPublishers.ofString("silly example"))
  .PUT()
  .asVoid();

Summary of default BodyPublishers

BodyPublishers.noBody()Sends no request body.
BodyPublishers.ofByteArray()byte[]
BodyPublishers.ofString()String, additional charset option
BodyPublishers.ofInputStream()InputStream
BodyPublishers.ofFile(Path file)Path with various options
BodyPublishers.fromPublisher(...)various options

Response body

java.net.http.HttpResponse.BodyHandler is the interface used to handle response bodies. The JDK HttpClient provides a number of BodyHandlers including reactive Flow based subscribers.

avaje-http-client has APIs to use the common ones such as String, Void, byte[] etc and withHandler() to use any BodyHandler implementation.

bean(Class<T>)

Return the response via BodyAdapter as a java bean/type (typically JSON). The BodyAdapter decodes from a byte[]. Gzip decoding occurs based on Content-Encoding header.

If the HTTP status code is in the error range this throws an HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

Customer customer = client.request()
  .path("customers/42")
  .GET()
  .bean(Customer.class);

list(Class<T>)

Return the response via BodyAdapter as a list of java beans/type (typically JSON). The BodyAdapter decodes from byte[]. Gzip decoding occurs based on Content-Encoding header.

If the HTTP status code is in the error range this throws an HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

List<Customer> customers = client.request()
  .path("customers")
  .GET()
  .list(Customer.class);

stream(Class<T>)

Return the response via BodyAdapter as a stream of java beans/type. The response media type is expected to be application/x-json-stream. Internally this uses HttpResponse.BodyHandlers.asLines() to get a stream of lines Stream<String>. In this case the BodyAdapter decodes from String content and there is no gzip decoding supported.

If the HTTP status code is in the error range this throws an HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

Stream<Customer> customers = client.request()
  .path("customers")
  .GET()
  .stream(Customer.class);

asVoid()

Return as HttpResponse<Void> but the response content is still available to be read in the case of error response.

If the HTTP status code is in the error range this throws an HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

HttpResponse<Void> hres = client.request()
  .path("hello")
  .GET()
  .asVoid();

asDiscarding()

Return a HttpResponse<Void> but the response content is always discarded. Unlike asVoid(), error content will not be available.

We use asDiscarding() when we only ever want the status code and headers etc and never want the response body.

HttpResponse<Void> hres = client.request()
  .path("hello")
  .GET()
  .asDiscarding();

asString()

Return as HttpResponse<String>.

HttpResponse<String> hres = client.request()
  .path("hello")
  .GET()
  .asString();

as()

Return as HttpResponse<T>.

HttpResponse<T> hres = client.request()
  .path("hello")
  .GET()
  .as(T.class);

withHandler()

Using withHandler() we can use any BodyHandler to process the response.

HttpResponse<InputStream> hres = client.request()
 .path("hello/stream")
 .GET()
 .withHandler(BodyHandlers.ofInputStream());

Summary of default BodyHandlers

BodyHandlers.discarding()Discards the response body
BodyHandlers.ofByteArray()byte[]
BodyHandlers.ofString()String, additional charset options
BodyHandlers.ofLines()Stream<String>
BodyHandlers.ofInputStream()InputStream
BodyHandlers.ofFile(Path file)Path with various options
BodyHandlers.ofByteArrayConsumer(...) 
BodyHandlers.fromSubscriber(...)various options
BodyHandlers.fromLineSubscriber(...)various options

HttpException

An HttpException is thrown when the status code is not in the 2XX range and the call is one of bean(), list(), stream(), asVoid().

HttpException effectively wraps the HttpResponse and provides helper methods to get the response body as String, byte[] or bean (via the registered body adapter).

int statusCode = httpException.statusCode();

// the underlying HttpResponse
HttpResponse<?> httpResponse = httpException.httpResponse()
int statusCode = httpResponse.statusCode();

// helper methods to read the response body
String errorBodyString = httpException.bodyAsString();
byte[] errorBodyBytes  = httpException.bodyAsBytes();

// helper method to read the response body as a bean (typically using Jackson/Gson)
MyErrorBean errorResponse = httpException.bean(MyErrorBean.class);

Exception Mapping

To automatically map HttpException into a custom exception you can provide a mapper function globally or per request.

HttpClient client = HttpClient.builder()
.baseUrl("https://api.github.com")
//set a global mapper any http exceptions will be mapped using this function
.globalErrorMapper(httpException -> new CustomException(httpException))
.build();

//can also set a mapper per request (will override the global one)
client.request()
      .errorMapper(httpException -> new CustomException(httpException))
      .GET()
      .asPlainString();

On request failure the function will receive the HttpException to be mapped into your custom exception and be rethrown.

Client Generation

If a method has a throws clause, and the exception has a constructor that takes a single HttpException, then the generated code will automatically register the contructor as an error mapper.

Given the below exception and client interface:

public class CustomException extends RuntimeException {

  public CustomException(HttpException httpException) {
    //process the exception (perhaps get the status code or response body)
  }
  ...
}
@Client
public interface ApiClient {

  @Get("/mapped")
  String mapped() throws CustomException;
}

The below implementation will be generated:

@Generated("avaje-http-client-generator")
public class ApiClientHttpClient implements ApiClient {

  private final HttpClient client;

  public ApiClientHttpClient(HttpClient client) {
    this.client = client;
  }

  // GET /mapped
  @Override
  public String mapped() {
    return client.request()
      .path("mapped")
      .errorMapper(CustomException::new) // error mapper registered
      .GET()
      .asPlainString().body();
  }

}

Async

All requests can be made using async returning CompletableFuture. Commonly the whenComplete() callback will be used to process the async responses.

The bean(), list(), stream() and asVoid() responses throw a HttpException if the status code is not in the 2XX range.

Reference API javadoc HttpAsyncResponse

Example: async().asString()

client.request()
 .path("hello/world")
 .GET()
 .async().asString()
 .whenComplete((hres, throwable) -> {

   if (throwable != null) {
     ...
   } else {
     int statusCode = hres.statusCode();
     String body = hres.body();
     ...
   }
 });

Example: async().bean(Class<T>)

client.request()
 .path("customer").path(42)
 .GET()
 .async().bean(Customer.class)
 .whenComplete((customer, throwable) -> {

   if (throwable != null) {
     HttpException httpException = (HttpException) throwable.getCause();
     int statusCode = httpException.getStatusCode();

     // maybe convert json error response body to a bean (using Jackson/Gson)
     MyErrorBean errorResponse = httpException.bean(MyErrorBean.class);
     ..

   } else {
     // use customer
     ...
   }
 });

Example: async().withHandler()

The example below is a line subscriber processing response content line by line.

CompletableFuture<HttpResponse<Void>> future = client.request()
 .path("hello/lineStream")
 .GET().async()
 .withHandler(HttpResponse.BodyHandlers.fromLineSubscriber(new Flow.Subscriber<>() {

   @Override
   public void onSubscribe(Flow.Subscription subscription) {
     subscription.request(Long.MAX_VALUE);
   }
   @Override
   public void onNext(String item) {
     // process the line of response content
     ...
   }
   @Override
   public void onError(Throwable throwable) {
     ...
   }
   @Override
   public void onComplete() {
     ...
   }
 }))
 .whenComplete((hres, throwable) -> {
   int statusCode = hres.statusCode();
   ...
 });

Testing with async() and join()

When writing tests using .async() we can use .join() such that the current thread waits for the async processing to complete. We then execute asserts knowing the async processing has completed.

Example: join()

client.request()
 .path("customer").path(42)
 .GET()
 .async().bean(Customer.class)
 .whenComplete((customer, throwable) -> {
   ...
 }).join(); // wait for async processing to complete

// can assert here after the join() if desired
assertThat(...)

Executor and common pool

JDK-8204339 - HTTP Client Dependent tasks should run in the common pool means that by default, async callbacks are executed using the ForkJoinPool.commonPool() even when an Executor is assigned to the HttpClient. This can be unexpected behavior, also refer to this stackoverflow discussion on this behavior.

HttpCall

If we are creating an API and want the client code to choose to execute the request asynchronously or synchronously then we can use call(). This is similar to Retrofit Call for people who are familiar with that.

The client can then choose to execute() the request synchronously or choose async() to execute the request asynchronously.

Reference API javadoc HttpCall

Example: call()

HttpCall<List<Customer>> call = client.request()
  .path("customers")
  .GET()
  .call().list(Customer.class);

// Either execute synchronously
List<Customer> customers =  call.execute();

// Or execute asynchronously
call.async()
  .whenComplete((customers, throwable) -> {
    ...
  });

Retry

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;
  }

Interceptors

To support request interception, you can add one or more interceptors with the .requestIntercept(yourinterceptors) on the client builder.

The RequestIntercept interface provides methods for performing actions before/after a request is sent.

Here is an example implementation.

class MyIntercept implements RequestIntercept {
  int counter;
  long responseTimeMicros;
  long customAttributeTimeMillis;
  String label;

  @Override
  public void beforeRequest(HttpClientRequest request) {
    // we can augment a request before it's sent
    request.header("NEW_HEADER", "val");
    request.setAttribute("MY_START_TIME", System.currentTimeMillis()).label();
  }

  @Override
  public void afterResponse(HttpResponse<?> response, HttpClientRequest request) {

    long start = request.getAttribute("MY_START_TIME");
    long customAttributeTimeMillis = System.currentTimeMillis() - start;
  }
}

Authorization

avaje-http-client has built in support for basic auth and bearer token based authorization.

Basic Auth

We can use BasicAuthIntercept to intercept all requests adding a Authorization: Basic ... header.

Example

HttpClient client = HttpClient.builder()
  .baseUrl(baseUrl)
  ...
  .requestIntercept(new BasicAuthIntercept("myUsername", "myPassword"))  <!-- HERE
  .build();

Bearer token Authorization - AuthTokenProvider

For Authorization using Bearer tokens that are obtained and expire after some time, implement AuthTokenProvider and register that when building the HttpClient.

Step 1. Implement AuthTokenProvider

class MyAuthTokenProvider implements AuthTokenProvider {

  @Override
  public AuthToken obtainToken(HttpClientRequest tokenRequest) {
    MyAuthTokenResponse res = tokenRequest
      .url("https://foo/v2/token")
      .header("content-type", "application/json")
      .body(authRequestAsJson())
      .POST()
      .bean(MyAuthTokenResponse.class);

    Instant validUntil = Instant.now().plusSeconds(res.expiresIn()).minusSeconds(60);

    return AuthToken.of(res.accessToken(), validUntil);
  }
}

Step 2. Register with HttpClient

HttpClient client = HttpClient.builder()
  .baseUrl("https://foo")
  ...
  .authTokenProvider(new MyAuthTokenProvider()) <!-- HERE
  .build();

Token will be obtained and set automatically

All requests using the HttpClient will automatically get an Authorization header with the retrieved Bearer token added. The token will be obtained for the initial request and then renewed when the token has expired.

Logging

By default request response logging is built in (via RequestLogger) and we enable it via setting the log level to DEBUG or TRACE for io.avaje.http.client.RequestLogger

Example: Logback
<logger name="io.avaje.http.client.RequestLogger" level="trace"/>

DEBUG

Summary logging that includes the response status code and execution time is logged at DEBUG level.

TRACE

Logging that includes the request and response headers plus the body content is logged at TRACE level.

Suppression

Logging is suppressed for Authorization headers and requests used by AuthTokenProvider. Additionally logging can be suppressed on requests via HttpClientRequest.suppressLogging().

Client API

We can define an interface and have the implementation generated. Similar to JAX-RS Client, Retrofit, Feign.

When using Client API we need to add a dependency for avaje-http-api which has the annotations and also add the annotation processor avaje-http-client-generator.

Getting started

Goto Getting started with Client API for step by step instructions to get started.

@Client

If we are creating the interface we can put @Client on the interface to indicate that the generator should generate an implementation for it.

Example client API
@Client
interface GitHub {

  @Get("/repos/{owner}/{repo}/contributors")
  List<Contributor> contributors(String owner, String repo);

}

@Client.Import

If the interface is already defined (in say another project) then we can instead use @Client.Import to indicate to the generator that it should generate an implementation for the interface defined by @Client.Import.

We can put the @Client.Import on a package-info or any type. Most commonly we would put it on the top level package-info.

Example client import
@Client.Import(types = org.foo.MyInterface.class)
package org.bar;

import io.avaje.http.api.Client;

Obtain/use API

We obtain the API implementation via httpClient.create(T) passing our client interface type T.

Example: obtain implementation

final HttpClient httpClient = HttpClient.builder()
        .baseUrl("https://api.github.com")
        .bodyAdapter(new JsonbBodyAdapter())
        .build();

// obtain API implementation
final Github github = httpClient.create(Github.class);

// use it
List<Contributor> contributors = github.contributors("ebean-orm", "ebean");

Generated implementation

The avaje-http-client-generator annotation processor generates the implementation as java source code. The generated source is typically found in: target/generated-sources/annotations

 

For more on IntelliJ IDEA setup of generated source.

 

Example: Generated source

The generated code for the Github interface above is:

package org.example.httpclient;


import io.avaje.http.api.*;
import io.avaje.http.client.HttpClient;
import java.util.List;
import org.example.Contributor;
import org.example.Github;

@Generated("avaje-http-client-generator")
public class Github$HttpClient implements Github {

  private final HttpClient clientContext;

  public Github$HttpClient(HttpClient ctx) {
    this.clientContext = ctx;
  }

  // GET /repos/{owner}/{repo}/contributors
  @Override
  public List<Contributor> contributors(String owner, String repo) {
    return clientContext.request()
      .path("repos").path(owner).path(repo).path("contributors")
      .GET()
      .list(Contributor.class);
  }

}

Response types

Below is a table of the API response types can be used and the resulting generated code.

Method response type Generated code
voidasVoid()
StringasPlainString().body()
Tbean(T.class)
List<T>list(T.class)
Stream<T>stream(T.class)
HttpResponse<Void>asVoid()
HttpResponse<String>asString()
HttpResponse<T>as(T.class)
HttpResponse<byte[]>asByteArray()
HttpResponse<InputStream>asInputStream()
HttpResponse<Stream<String>>asLines()
  
  
CompletableFuture<T>async().bean(T.class)
CompletableFuture<List<T>>async().list(T.class)
CompletableFuture<Stream<T>>async().stream(T.class)
CompletableFuture<HttpResponse<Void>>async().asVoid()
CompletableFuture<HttpResponse<String>>async().asString()
CompletableFuture<HttpResponse<T>>async().as(T.class)
CompletableFuture<HttpResponse<byte[]>>async().asByteArray()
CompletableFuture<HttpResponse<InputStream>>async().asInputStream()
CompletableFuture<HttpResponse<Stream<String>>>async().asLines()
  
HttpCall<T>call().bean(T.class)
HttpCall<List<T>>call().list(T.class)
HttpCall<Stream<T>>call().stream(T.class)
HttpCall<HttpResponse<Void>>call().asVoid()
HttpCall<HttpResponse<String>>call().asString()
HttpCall<HttpResponse<T>>call().as(T.class)
HttpCall<HttpResponse<byte[]>>call().asByteArray()
HttpCall<HttpResponse<InputStream>>call().asInputStream()
HttpCall<HttpResponse<Stream<String>>>call().asLines()

HttpResponse.BodyHandler parameter

The method can also declare one parameter of type java.net.http.HttpResponse.BodyHandler and this will be used to handle the response body. This can be a specific generic type like BodyHandler<Path> or more general type like BodyHandler<T>.

@Get("/{id}")
HttpResponse<Path> getWithHandler(String id, HttpResponse.BodyHandler<Path> myHandler, String other);

The generated code uses the BodyHandler parameter to handle the response body. The resulting generated code includes .withHandler(myHandler):

// GET /{id}
@Override
public HttpResponse<Path> getWithHandler(String id, BodyHandler<Path> myHandler, String other) {
  return clientContext.request()
    .path(id)
    .queryParam("other", other)
    .GET()
    .withHandler(myHandler);
}

We can also use a more general handler like HttpResponse.BodyHandler<T>. For example:

@Get("/{id}")
<T> HttpResponse<T> getWithGeneralHandler(String id, HttpResponse.BodyHandler<T> myHandler);

The generated code uses the BodyHandler parameter to handle the response body.

// GET /{id}
@Override
public <T> HttpResponse<T> getWithGeneralHandler(String id, BodyHandler<T> myHandler) {
  return clientContext.request()
    .path(id)
    .GET()
    .withHandler(myHandler);
}

HttpRequest.BodyPublisher parameter

The method can also declare one parameter of type java.net.http.HttpRequest.BodyPublisher and this will be used for the request body.

@Post("/{id}/foo/{name}")
HttpResponse<Void> postWithBody(String id, String name, HttpRequest.BodyPublisher body);

The generated code uses the BodyPublisher parameter as the request body .body(body)

// POST /{id}/foo/{name}
@Override
public HttpResponse<Void> postWithBody(String id, String name, BodyPublisher body) {
  return clientContext.request()
    .path(id).path("foo").path(name)
    .body(body)
    .POST()
    .asVoid();
}

@Path

We can optionally put @Path on the interface type. This path is prefixed to the paths defined on the verb annotations @Get, @Post, @Put ....

For example, with @Path("/api/cats") all the paths defined have /api/cats prefixed.

@Client
@Path("/api/cats")
public interface Cats {

  @Get("{id}")
  Cat byId(UUID id);

  @Get("{id}/operations")
  List<Operation> operations(UUID id, OffsetDateTime since);

  @Get("all")
  Stream<Cat> all();

  @Post
  void create(Cat cat);
}

The generated paths for the methods above all have /api/cats prefix:

// GET {id}
.path("api").path("cats").path(id)

// GET {id}/operations
.path("api").path("cats").path(id).path("operations")

// GET all
.path("api").path("cats").path("all")

// POST
.path("api").path("cats")

Property Paths

A path can by configured via system properties or avaje config. Property parameters start with ${ and end with }. These will be evaluated on class load.

@Get("/${get.path}")
List<Bazz> findBazz();

The generated code will generate code to retrieve the value.

// GET /{get.path}
// Avaje Config will be used if detected on classpath
private static final String GET_PATH = System.getProperty("get.path")
@Override
public List<Bazz> findBazz() {
  return clientContext.request()
    .path(GET_PATH)
    .GET()
    .list(Bazz.class);
}

Path parameters

Path parameters start with { and end with } and matched to method parameter names. So unlike JAX-RS we do not need a @PathParam annotation.

For example {id}, {startDate}, {type} in the path are matched to method parameters of the same name.

@Get("/{id}/{startDate}/{type}")
List<Bazz> findBazz(long id, LocalDate startDate, String type);

The generated code includes .path(id).path(startDate).path(type):

// GET /{id}/{startDate}/{type}
@Override
public List<Bazz> findBazz(long id, LocalDate startDate, String type) {
  return clientContext.request()
    .path(id).path(startDate).path(type)
    .GET()
    .list(Bazz.class);
}

Unlike JAX-RS we do not need a @PathParam annotation.

@RequestTimeout

Use @RequestTimeout to override the global response timout for a specific request.

@Client
interface CustomerApi {
  @Get("/{id}")
  @RequestTimeout(value = 1, ChronoUnit.SECONDS) //defaults to milliseceonds
  Customer getById(long id);
}

Use @Header for a header parameter. It the header parameter name is not explicitly specified then it is the init caps snake case of the parameter name.

userAgent -> User-Agent

lastModified -> Last-Modified

@Post
Bar postIt(Foo payload, @Header("User-Agent") String userAgent); // explicit

@Post
Bar postIt(Foo payload, @Header String userAgent); // User-Agent

@Post
Bar postIt(Foo payload, @Header Collection<String> accept); // Accept

@Get
Bazz find(@Header String lastModified); // Last-Modified

@Header Map<String, ?>

We can use @Header with a Map<String, ?>, Map<String, String> or Map<String, Object>.

@Get("{type}")
List<Cat> searchByType(String type, String query, @Header Map<String, ?> myHeaders);

The generated code includes .header(myHeaders)

// GET {type}
@Override
public List<Cat> searchByType(String type, String query, Map<String,?> myHeaders) {
  return clientContext.request()
    .header(myHeaders)
    .path("api").path("cats").path(type)
    .queryParam("query", query)
    .GET()
    .list(Cat.class);
}

@Headers

Use @Headers to set default header parameters.

@Client
@Headers("Content-Type: applicaton/json")
public interface TitanFall {

  @Get("/${titan}/${drop.point}")
  @Headers("Something: \\invalid\n\t")
  Titan titanFall();


  @Get("/${titan}/copium")
  @Headers("      Accept    :   applicaton/json")
  Titan titanFall3();
}

@Header Map<String, ?>

We can use @Header with a Map<String, ?>, Map<String, String> or Map<String, Object>.

@Get("{type}")
List<Cat> searchByType(String type, String query, @Header Map<String, ?> myHeaders);

The generated code includes .header(myHeaders)

// GET {type}
@Override
public List<Cat> searchByType(String type, String query, Map<String,?> myHeaders) {
  return clientContext.request()
    .header(myHeaders)
    .path("api").path("cats").path(type)
    .queryParam("query", query)
    .GET()
    .list(Cat.class);
}

@QueryParam

We explicitly specify query parameters using @QueryParam.

// Explicit query parameter order-by

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam("order-by") String orderBy);

The generated code includes .queryParam("order-by", orderBy)

// GET /{bornAfter}
@Override
public List<Cat> findCats(LocalDate bornAfter, String orderBy) {
  return clientContext.request()
    .path(bornAfter)
    .queryParam("order-by", orderBy)
    .GET()
    .list(Cat.class);
}

Implied query parameters

Query parameters can be implied by not being a path parameter. That is, if a method parameter does not match a path parameter then it is implied to be a query parameter.

The following 3 declarations are exactly the same with all 3 having a query parameters for orderBy

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam("orderBy") String orderBy);

@Get("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, @QueryParam String orderBy);

@Post("/{bornAfter}")
List<Cat> findCats(LocalDate bornAfter, String orderBy);  // orderBy implied as query parameter

We must explicitly use @QueryParam when the query parameter is not a valid java identifier. For example, if the query parameter includes a hyphen then we must use @QueryParam explicitly.

@QueryParam Map<String, ?>

We can use @QueryParam with a Map<String, ?>, Map<String, String> or Map<String, Object>

@Post
void newCat(Cat cat, String myParam, @QueryParam Map<String,?> extraParams);

The generated code includes .queryParam(extraParams)

// POST
@Override
public void newCat(Cat cat, String myParam, Map<String,?> extraParams) {
  clientContext.request()
    .path("api").path("cats")
    .queryParam("myParam", myParam)
    .queryParam(extraParams)
    .body(cat)
    .POST()
    .asVoid();
}

@BodyString

We can specify that a string parameter is a body using @BodyString.

@Post("/stringbody")
List<Cat> addCats(@BodyString String body);

The generated code will be:

@Override
public List<Cat> addCats(String body) {
  return clientContext.request()
    .path("stringbody")
    .body(body)
    .GET()
    .list(Cat.class);
}

@BeanParam

When we have a lot of query parameters or if we have query parameters that are common to many endpoints then we look to use @BeanParam.

We can create a bean and all the properties on the bean default to being query parameters and annotate this with @BeanParam.

public record Common(
  Long firstRow,
  Long maxRows,
  String sortBy,
  List<String> filters) { }

Use a record type or any type that has getter methods, record style getter methods or public fields.

public class Common {
  public Long firstRow;
  public Long maxRows;
  public String sortBy;
  public List<String> filters;
}

We annotate the method parameter with @BeanParam

@Get("search/{type}")
List<Cat> searchCats(String type, @BeanParam Common commonParams);

Generated code has queryParam() for each of the properties:

// GET search/{type}
@Override
public List<Cat> searchCats(String type, Common commonParams) {
  return clientContext.request()
    .path("search").path(type)
    .queryParam("firstRow", commonParams.firstRow())
    .queryParam("maxRows", commonParams.maxRows())
    .queryParam("sortBy", commonParams.sortBy())
    .queryParam("filters", commonParams.filters())
    .GET()
    .list(Cat.class);
}

JAX-RS @BeanParam

Our @BeanParam is the same as JAX-RS @BeanParam except the properties default to being query parameters. With JAX-RS we need to explicitly annotate each of the properties. We can do this because we also have @Form and "Form beans".

BeanParam with @Header properties

The properties on a "bean" default to being query parameters. We put @Header on properties that are instead headers.

public record Common(
  Long firstRow,
  Long maxRows,
  String sortBy,
  String filter,
  @Header("X-Mod")
  String modification) { }

We then get the modification value set as a header.

.header("X-Mod", commonParams.modification())

@Form

If a method has both @Post and @Form then the method parameters default to be form parameters.

In the following example name, email and url all default to be form parameters.

@Form @Post("register")
void register(String name, String email, String url);

The generated code includes .formParam() for name, email and url.

// POST register
@Override
public void register(String name, String email, String url) {
  clientContext.request()
    .path("register")
    .formParam("name", name)
    .formParam("email", email)
    .formParam("url", url)
    .POST()
    .asVoid();
}

@FormParam

For the example above we could alternatively use explicit @FormParam on each of the form parameters rather than @Form. We then get:

@Post("register")
void register(@FormParam String name, @FormParam String email, @FormParam String url);

The expectation is that we most often would use @Form because it reduces "annotation noise" and that we will very rarely use @FormParam. Potentially we only use @FormParam if the parameter name includes hyphen or similar characters that are not valid Java/Kotlin identifiers.

@FormParam Map<String, ?>

We can use @FormParam with a Map<String, ?>, Map<String, String> or Map<String, Object>.

@Put("{id}")
void register(UUID id, @FormParam Map<String, ?> params);

The generated code includes .formParam(params)

// PUT {id}
@Override
public void register(UUID id, Map<String,?> params) {
  clientContext.request()
    .path("api").path("cats").path(id)
    .formParam(params)
    .PUT()
    .asVoid();
}

@Form "Form Beans"

In the case where we are posting a form with a lot of parameters we can define a bean with properties for each of the form parameters rather than have a method with lots of arguments.

public record RegisterForm(String name, String email, String url) { }
@Form @Post("register")
void register(RegisterForm myForm);
The generated code for the above is:
// POST register
@Override
public void register(RegisterForm myForm) {
  clientContext.request()
    .path("register")
    .formParam("name", myForm.name())
    .formParam("email", myForm.email())
    .formParam("url", myForm.url())
    .POST()
    .asVoid();
}

"Form beans" are nice with large forms because they help de-clutter our code and the generated code takes care of putting the values into our bean properties so that we don't have to write.

This use of @Form is very similar to JAX-RS @BeanParam except that the bean properties default be being form parameters. With JAX-RS we would put a @FormParam on every property that is a form parameter which becomes a lot of annotation noise on a large form.

Form beans with @QueryParam, @Header properties

The properties on a "form bean" default to being form parameters. We put @QueryParam, @Header on properties that are instead query params, headers or cookies.

public record RegisterForm(
  String name,
  String email,
  String url,
  @QueryParam Boolean overrideFlag,
  @Header String xModified) { }

The generated code includes queryParam() and header() for those parameters:

// POST register
@Override
public void register(RegisterForm myForm) {
  clientContext.request()
    .path("register")
    .formParam("name", myForm.name())
    .formParam("email", myForm.email())
    .formParam("url", myForm.url())
    .queryParam("overrideFlag", myForm.overrideFlag())
    .header("X-Modified", myForm.xModified())
    .POST()
    .asVoid();
}

@Client Java Module Setup

If using java modules with generated Client Interfaces, in the module-info.java we need to:

  1. Add a requires clause for io.avaje.http.api
  2. Add a requires clause for io.avaje.http.client
  3. Add a provides clause for io.avaje.http.client.HttpClient.GeneratedComponent
Example module-info
import io.avaje.http.client.HttpClient.GeneratedComponent
module org.example {

  requires io.avaje.http.api;
  requires io.avaje.http.client
  // you must define the fully qualified class name of the generated classes. if you use an import statement, compilation will fail
  provides GeneratedComponent with org.example.GeneratedHttpComponent;
}

In the example above, org.example.GeneratedHttpComponent is generated code typically found in target/generated-sources/annotations.

10K requests - Loom vs Async

The following is a very quick and rough comparison of running 10,000 requests using Async vs Loom.

The intention is to test the thought that in a "future Loom world" the desire to use async() execution with HttpClient reduces.

TLDR: Caveat, caveat, more caveats ... initial testing shows Loom to be just a touch faster (~10%) than async.

To run my tests I use Jex as the server (Jetty based) and have it running using Loom. For whatever testing you do you will need a server that can handle a very large number of concurrent requests.

The Loom blocking request (make 10K of these)

HttpResponse<String> hres =  httpClient.request()
  .path("s200")
  .GET()
  .asString();

The equivalent async request (make 10K of these joining the CompletableFuture's).

CompletableFuture<HttpResponse<String>> future = httpClient.request()
  .path("s200")
  .GET()
  .async()
  .asString()
  .whenComplete((hres, throwable) -> {
    ...
  });

10K requests using Async and reactive streams

Use .async() to execute the requests which internally is using JDK HttpClient's reactive streams. The whenComplete() callback is invoked when the response is ready. Collect all the resulting CompletableFuture and wait for them all to complete.

Outline:

// Collect all the CompletableFuture's
List<CompletableFuture<HttpResponse<String>>> futures = new ArrayList<>();

for (int i = 0; i < 10_000; i++) {
  futures.add(httpClient.request().path("s200")
    .GET()
    .async().asString()
    .whenComplete((hres, throwable) -> {
        // confirm 200 response etc
        ...
    }));
}

// wait for all requests to complete via join() ...
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

10K requests using Loom

With Loom Java 17 EA Release we can use Executors.newVirtualThreadExecutor() to return an ExecutorService that uses Loom Virtual Threads. These are backed by "Carrier threads" (via ForkedJoinPool).

Outline:

// use Loom's Executors.newVirtualThreadExecutor()
try (ExecutorService executorService = Executors.newVirtualThreadExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executorService.submit(this::task);
    }
}
private void task() {
  HttpResponse<String> hres =
    httpClient.request().path("s200")
     .GET()
     .asString();

  // confirm 200 response etc
  ...
}

Caveat: Proper performance benchmarks are really hard and take a lot of effort.

Running some "rough/approx performance comparison tests" using Loom build 17 EA 2021-09-14 / (build 17-loom+7-342) vs Async for my environment and 10K request scenarios has loom execution around 10% faster than async.

It looks like Loom and Async run in pretty much the same time although it currently looks that Loom is just a touch faster (perhaps due to how it does park/unpark). More investigation required.

Date: 2021-06
Build: 17 EA 2021-09-14 / (build 17-loom+7-342).

  openjdk version "17-loom" 2021-09-14
  OpenJDK Runtime Environment (build 17-loom+7-342)
  OpenJDK 64-Bit Server VM (build 17-loom+7-342, mixed mode, sharing)