Avaje Http Client
Discord | Source | API Docs | Issues | Releases |
---|---|---|---|---|
Discord | Github | Javadoc | Github |
A lightweight wrapper to the JDK 11+ Java Http Client.
- Requires Java 11+
- Adds a fluid API for building URLs and payloads
- Adds JSON marshalling/unmarshalling of request and response using Jackson, Gson or avaje-jsonb
- Gzip encoding/decoding
- Logging of request/response logging
- Interception of request/response
- Built in support for authorization via Basic Auth and Bearer Tokens
- Provides async and sync API
JDK HttpClient Introduction
Some introductions to the JDK HttpClient:
- OpenJDK HttpClient Introduction
- A closer look at the Java 11 HTTP Client
- Javadoc for 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).
- Generates the implementation as source code via annotation processing
- No reflection and no use of dynamic proxies
- Targets avaje-http-client / JDK HttpClient only
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:
- No built-in support for multipart-form body (Though some libraries provide bodyPublishers that can be used to send multi-part data)
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
- An Object that is written by the BodyAdapter, e.g. JsonbBodyAdapter writes as JSON content
- formParam() for url encoded form
- byte[], String, Path (file), InputStream
- Any HttpRequest.BodyPublisher
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 |
---|---|
void | asVoid() |
String | asPlainString().body() |
T | bean(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);
}
@Header
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);
// 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:
- Add a requires clause for io.avaje.http.api
- Add a requires clause for io.avaje.http.client
- 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)