avaje http client

License Source API Docs Issues Releases
Apache2 Github Javadoc Github Latest 1.11

 

Wrap JDK HttpClient

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

Client API generation

Optionally 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 is similar to Retrofit, Feign and JAX-RS client for people familiar with those projects. 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 and no use of dynamic proxies.

Example client API
@Client
interface GitHub {

  @Get("/repos/{owner}/{repo}/contributors")
  List<Contributor> contributors(String owner, String repo);
}
API allowing async or sync use via HttpCall
@Client
interface GitHub {

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

The client API can also be a useful target given a openapi/swagger spec. Given a openapi spec generate a client API (or client and server API). The plan is to add a maven plugin that takes an openapi spec and generates the associated API but this does not exist just yet.

Getting started

Dependencies

Maven

Add avaje-http-client as a dependency.

<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-http-client</artifactId>
  <version>1.11</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>1.11</version>
</dependency>

Client API - Annotation processor

Add the avaje-http-client-generator annotation processor. We can add it as a provided scope dependency or ...

<!-- Annotation processors -->
<dependency>
  <groupId>io.avaje</groupId>
  <artifactId>avaje-http-client-generator</artifactId>
  <version>1.11</version>
  <scope>provided</scope>
</dependency>

If there are other annotation processors and they are specified via maven-compiler-plugin annotationProcessorPaths then we add avaje-http-client-generator there instead.

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-compiler-plugin</artifactId>
  <configuration>
    <annotationProcessorPaths> <!-- All annotation processors specified here -->
      <path>
        <groupId>io.avaje</groupId>
        <artifactId>avaje-http-client-generator</artifactId>
        <version>1.11</version>
      </path>
      <path>
          ... other annotation processor ...
      </path>
    </annotationProcessorPaths>
  </configuration>
</plugin>

Gradle

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

dependencies {

  implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3'
  implementation 'io.avaje:avaje-http-client:1.11'
  ...
}

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

dependencies {
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3'
    implementation 'io.avaje:avaje-http-client:1.11'

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

Limitations

Current notable limitations are:

JDK HttpClient Introduction

Some introductions to the JDK HttpClient:

Other JDK HttpClient projects

Methanol is another project based on JDK HttpClient. Let me know of others and I'll add them to the list below.

Quick overview

HttpClientContext

Create a HttpClientContext with a base URL and BodyAdapter (plus other options as necessary like retry, interceptors etc).

Use HttpClientContext to create requests. Requests can be executed synchronously or asynchronously with CompletableFuture.

HttpClientContext httpClientContext = HttpClientContext.newBuilder()
  .baseUrl(baseUrl)
  .bodyAdapter(new JacksonBodyAdapter())
  //.bodyAdapter(new GsonBodyAdapter(new Gson()))
  .build();

Example GET

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

Example GET Async

httpClientContext.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 manor or via async() in an asynchronous style using CompletableFuture.

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 these or our own HttpResponse.BodyHandler implementation.

Loom vs Async

With Loom we will be able to use simple blocking requests and get a similar behaviour as the async() requests. This is an important factor to keep in mind.

See 10K requests - Loom vs Async

HttpClientContext

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

Create a HttpClientContext with a base URL plus BodyAdapter (e.g. JacksonBodyAdapter). Additionally set other options like retry and request interceptors as necessary.

Reference HttpClientContext.Builder

Creating HttpClientContext

HttpClientContext httpClientContext =
  HttpClientContext.newBuilder()
    .baseUrl(baseUrl)
    .bodyAdapter(new JacksonBodyAdapter())
    // .bodyAdapter(new GsonBodyAdapter(new Gson()))
    .build();

From the HttpClientContext instance we create requests and execute them.

Create and execute requests

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

Request body

java.net.http.HttpRequest.BodyPublisher is used for request body content. JDK HttpClient comes with a number of implementations which are great and we use. In addition we can use our own implementations.

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 HttpClientContext we register a message body handler. There is a supplied JacksonBodyAdapter and GsonBodyAdapter. When these are registered then when we call body(object) passing an Object this uses that body adapter to write the body content as JSON.

Example: POST object as json

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

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

formParam

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

Example: POST using formParams

HttpResponse<Void> res = clientContext.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 = clientContext.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

Supplier<InputStream> supplier = ...;

HttpResponse<String> res = clientContext.request()
  .path("upload")
  .body(supplier)
  .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 = clientContext.request()
  .path("whazzzup")
  .body(HttpRequest.BodyPublishers.ofString("silly example"))
  .PUT()
  .asVoid();

Response body

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

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

Summary of BodyHandlers

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

bean(Class<T>)

Return the response via BodyAdapter as a java bean/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 a HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

Customer customer = clientContext.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 a HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

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

stream(Class<T>)

Return the response via BodyAdapter as a stream of java beans/type. The response 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 a HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

Stream<Customer> customers = clientContext.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 a HttpException. From the HttpException we can get the underlying HttpResponse and error response body.

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

asDiscarding()

Return as HttpResponse<Void> but the response content is always discarded. Unlike asVoid() if there is an error response with content error content that 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 = clientContext.request()
  .path("hello")
  .GET()
  .asDiscarding();

asString()

Return as HttpResponse<String>.

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

withHandler()

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

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

HttpException

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

Reference API javadoc HttpException

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

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

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()

clientContext.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>)

clientContext.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 = clientContext.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();
   ...
 });

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 =
  clientContext.request()
    .path("customers")
    .GET()
    .call().list(Customer.class);

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

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

Authorisation

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

Basic Auth

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

Example

HttpClientContext clientContext = HttpClientContext.newBuilder()
  .baseUrl(baseUrl)
  ...
  .requestIntercept(new BasicAuthIntercept("myUsername", "myPassword"))  <!-- HERE
  .build();

Bearer token Authorization - AuthTokenProvider

For Authorization using Bearer tokens that are obtained and expire, implement AuthTokenProvider and register that when building the HttpClientContext.

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 HttpClientContext

HttpClientContext ctx = HttpClientContext.newBuilder()
  .baseUrl("https://foo")
  ...
  .authTokenProvider(new MyAuthTokenProvider()) <!-- HERE
  .build();

Token obtained and set automatically

All requests using the HttpClientContext will automatically get an Authorization header with Bearer token added. The token will be obtained for 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 httpClientContext.create(T) passing our client interface type T.

Example: obtain implementation

// create HttpClientContext
final HttpClientContext httpClientContext = HttpClientContext.newBuilder()
        .baseUrl("https://api.github.com")
        .bodyAdapter(new JacksonBodyAdapter())
        .build();

// obtain API implementation
final Github github = httpClientContext.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.HttpClientContext;
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 HttpClientContext clientContext;

  public Github$HttpClient(HttpClientContext 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 tabe 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<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<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<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")

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.

@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);

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

Note that 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();
}

@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,
  String filter) { }

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

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("filter", commonParams.filter())
    .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 forms with lots of properties because they 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 that code.

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

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

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

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)