Annotation-driven REST client for GuicedEE applications using the Vert.x 5 WebClient.
Declare an @Endpoint on any RestClient<Send, Receive> field, add @Named, and inject β URL, HTTP method, authentication, timeouts, and connection options are all driven from the annotation. Every call returns a Uni<Receive> for fully reactive composition.
Built on Vert.x Web Client Β· Google Guice Β· Mutiny Β· Jackson Β· JPMS module com.guicedee.rest.client Β· Java 25+
<dependency>
<groupId>com.guicedee</groupId>
<artifactId>rest-client</artifactId>
</dependency>Gradle (Kotlin DSL)
implementation("com.guicedee:rest-client:2.0.0-SNAPSHOT")- Zero-boilerplate REST calls β annotate a field with
@Endpointand@Named, inject, callsend()orpublish() - Fully typed β
RestClient<Send, Receive>preserves generic types for Jackson (de)serialization, including collections and nested generics - Reactive β every call returns
Uni<Receive>for non-blocking composition; fire-and-forget viapublish() - Annotation-driven configuration β URL, HTTP method, protocol version, authentication, timeouts, and connection tuning all declared on the field
- Multiple authentication strategies β Bearer/JWT, Basic, and API Key with environment variable placeholders for secrets
- Path parameters β
{paramName}placeholders in URLs with a fluentpathParam()builder, URL-encoded per RFC 3986 - Environment variable overrides β every annotation attribute can be overridden via
REST_CLIENT_*environment variables without code changes - Package-level
@Endpointβ declare shared base URL and options onpackage-info.javafor all clients in a package RestClientConfiguratorSPI β hook into WebClient construction for custom SSL, proxy, or other Vert.x options- Rich error model β
RestClientExceptioncarries HTTP status, status message, response body, and cause withisClientError()/isServerError()/isTransportError()helpers - Cached WebClients β one
WebClientper endpoint name, reused across injections
Step 1 β Declare a REST client field:
public class UserService {
@Endpoint(url = "https://api.example.com/users", method = "POST",
security = @EndpointSecurity(value = SecurityType.Bearer,
token = "${API_TOKEN}"))
@Named("create-user")
private RestClient<CreateUserRequest, UserResponse> createUserClient;
public Uni<UserResponse> createUser(CreateUserRequest request) {
return createUserClient.send(request);
}
}Step 2 β Bootstrap GuicedEE (endpoints are discovered and bound automatically):
IGuiceContext.registerModuleForScanning.add("my.app");
IGuiceContext.instance();That's it. RestClientRegistry discovers the @Endpoint field, RestClientBinder creates the Guice binding, and the RestClient is ready for injection.
Note: Only one field needs the
@Endpointannotation to define the endpoint. After that, any other class can inject the same client using just@Injectand@Namedβ no@Endpointrequired:public class OrderService { @Inject @Named("create-user") private RestClient<CreateUserRequest, UserResponse> createUserClient; }
Startup
IGuiceContext.instance()
ββ IGuicePreStartup hooks
ββ RestClientPreStartup (scans for @Endpoint-annotated fields)
ββ IGuiceModule hooks
ββ RestClientBinder (binds RestClient instances per @Named endpoint)
ββ RestClientRegistry (metadata: URL, types, security, options)
ββ WebClient cache (one Vert.x WebClient per endpoint)
ββ RestClientConfigurator (SPI for custom WebClientOptions)
restClient.send(body)
β Uni.createFrom().deferred()
β Resolve path parameters ({param} β value)
β Parse URI (host, port, path, scheme)
β Create HttpRequest via WebClient
β Apply timeouts (connect, idle, read)
β Apply default headers (Accept, Content-Type)
β Apply authentication (Bearer / Basic / ApiKey)
β Apply query params from URI + additional params
β Serialize body via DefaultObjectMapper β Buffer
β Send request (sendBuffer / send)
β Map response:
2xx β Deserialize body via DefaultObjectMapper β Receive
non-2xx β RestClientException (status, message, body)
transport error β RestClientException (cause)
The primary way to declare a REST client β annotate a RestClient<Send, Receive> field:
@Endpoint(url = "https://api.example.com/users/{userId}",
method = "GET",
options = @EndpointOptions(connectTimeout = 5000, readTimeout = 10000))
@Named("get-user")
private RestClient<Void, UserResponse> getUserClient;Declare a shared base URL and default options for all clients in a package via package-info.java:
@Endpoint(url = "http://localhost:8080/api",
options = @EndpointOptions(readTimeout = 1000))
package com.example.api;
import com.guicedee.rest.client.annotations.Endpoint;
import com.guicedee.rest.client.annotations.EndpointOptions;Field-level URLs that start with / are appended to the package-level base URL.
The @Named annotation determines the Guice binding key. Once an @Endpoint is registered, the client can then be injected by name.
// Inject by name
@Named("create-user")
@Inject
private RestClient<CreateUserRequest, UserResponse> client;
// Or retrieve programmatically
RestClient<?, ?> client = IGuiceContext.get(
Key.get(RestClient.class, Names.named("create-user")));Uni<UserResponse> response = client.send();Uni<UserResponse> response = client.send(new CreateUserRequest("Alice"));Uni<UserResponse> response = client.send(
body,
Map.of("X-Request-Id", "abc123"), // headers
Map.of("page", "1", "limit", "10") // query params
);Use {paramName} placeholders in the endpoint URL:
@Endpoint(url = "https://api.example.com/users/{userId}/orders/{orderId}",
method = "GET")
@Named("get-order")
private RestClient<Void, OrderResponse> orderClient;
// Fluent builder
Uni<OrderResponse> response = orderClient
.pathParam("userId", "123")
.pathParam("orderId", "456")
.send();Path parameter values are URL-encoded automatically (RFC 3986). Unresolved placeholders throw IllegalArgumentException.
Uni<Void> done = client.publish(body);Chain path parameters, headers, and query parameters:
Uni<OrderResponse> response = orderClient
.pathParam("userId", "123")
.pathParam("orderId", "456")
.headers(Map.of("X-Correlation-Id", correlationId))
.queryParams(Map.of("expand", "items"))
.send();Response bodies are deserialized using the DefaultObjectMapper (Jackson) based on the Receive type parameter:
| Receive type | Behavior |
|---|---|
String |
Response body as UTF-8 string |
byte[] |
Raw response bytes |
| Any class | Jackson deserialization via full generic type |
Void / Object |
Raw Buffer returned |
| Status | Behavior |
|---|---|
| 2xx | Deserialized body returned in Uni |
| Non-2xx | Uni.failure(RestClientException) with status, message, and body |
| Transport error | Uni.failure(RestClientException) with cause |
client.send(request)
.subscribe().with(
response -> log.info("Success: {}", response),
err -> {
if (err instanceof RestClientException rce) {
if (rce.isClientError()) {
log.warn("Client error {}: {}", rce.getStatusCode(), rce.getResponseBody());
} else if (rce.isServerError()) {
log.error("Server error {}: {}", rce.getStatusCode(), rce.getStatusMessage());
} else if (rce.isTransportError()) {
log.error("Connection failed: {}", rce.getMessage());
}
}
}
);Authentication is configured via the security attribute of @Endpoint:
@Endpoint(url = "https://api.example.com/data",
security = @EndpointSecurity(value = SecurityType.Bearer,
token = "${API_TOKEN}"))SecurityType |
Header | Source attributes |
|---|---|---|
Bearer |
Authorization: Bearer <token> |
token |
JWT |
Authorization: Bearer <token> |
token |
Basic |
Authorization: Basic <base64> |
username, password |
ApiKey |
<apiKeyHeader>: <apiKey> |
apiKey, apiKeyHeader (default X-API-Key) |
None |
(no authentication) | β |
All credential values support ${VAR_NAME} syntax, resolved via system properties or environment variables:
@EndpointSecurity(value = SecurityType.Bearer, token = "${API_TOKEN}")export API_TOKEN=eyJhbGciOiJIUzI1NiIs...Security values can also be overridden via naming convention:
| Variable | Purpose |
|---|---|
REST_CLIENT_TOKEN_<endpointName> |
Override Bearer/JWT token |
REST_CLIENT_USERNAME_<endpointName> |
Override Basic username |
REST_CLIENT_PASSWORD_<endpointName> |
Override Basic password |
REST_CLIENT_API_KEY_<endpointName> |
Override API key |
REST_CLIENT_API_KEY_HEADER_<endpointName> |
Override API key header name |
Connection tuning and behavior options. All timing values are in milliseconds; 0 means "use Vert.x default":
| Attribute | Default | Purpose |
|---|---|---|
connectTimeout |
0 |
TCP connect timeout (ms) |
idleTimeout |
0 |
Idle connection timeout (ms) |
readTimeout |
0 |
Response timeout per request (ms) |
trustAll |
false |
Trust all server certificates (dev only) |
verifyHost |
true |
Verify server hostname against certificate |
ssl |
false |
Force SSL/TLS (auto-detected from https URLs) |
maxPoolSize |
0 |
Max connection pool size |
keepAlive |
true |
TCP keep-alive between requests |
decompression |
true |
Auto-decompress gzip/deflate responses |
followRedirects |
true |
Follow HTTP redirects automatically |
maxRedirects |
0 |
Max redirect hops |
defaultContentType |
application/json |
Default Content-Type header |
defaultAccept |
application/json |
Default Accept header |
retryAttempts |
0 |
Retry count on connection failure |
retryDelay |
1000 |
Delay between retries (ms) |
@Endpoint(url = "https://slow-api.example.com/data",
method = "GET",
options = @EndpointOptions(
connectTimeout = 5000,
readTimeout = 30000,
maxPoolSize = 10,
retryAttempts = 3,
retryDelay = 2000
))
@Named("slow-api")
private RestClient<Void, DataResponse> slowApiClient;Every @EndpointOptions attribute can be overridden per endpoint via environment variables:
| Variable | Purpose |
|---|---|
REST_CLIENT_URL_<name> |
Override endpoint URL |
REST_CLIENT_METHOD_<name> |
Override HTTP method |
REST_CLIENT_PROTOCOL_<name> |
Override protocol (HTTP / HTTP2) |
REST_CLIENT_CONNECT_TIMEOUT_<name> |
Override connect timeout |
REST_CLIENT_IDLE_TIMEOUT_<name> |
Override idle timeout |
REST_CLIENT_READ_TIMEOUT_<name> |
Override read timeout |
REST_CLIENT_TRUST_ALL_<name> |
Override trust-all flag |
REST_CLIENT_VERIFY_HOST_<name> |
Override hostname verification |
REST_CLIENT_SSL_<name> |
Override SSL flag |
REST_CLIENT_MAX_POOL_SIZE_<name> |
Override connection pool size |
REST_CLIENT_KEEP_ALIVE_<name> |
Override keep-alive flag |
REST_CLIENT_DECOMPRESSION_<name> |
Override decompression flag |
REST_CLIENT_FOLLOW_REDIRECTS_<name> |
Override follow-redirects flag |
REST_CLIENT_MAX_REDIRECTS_<name> |
Override max redirect hops |
REST_CLIENT_CONTENT_TYPE_<name> |
Override default Content-Type |
REST_CLIENT_ACCEPT_<name> |
Override default Accept |
REST_CLIENT_RETRY_ATTEMPTS_<name> |
Override retry count |
REST_CLIENT_RETRY_DELAY_<name> |
Override retry delay |
Where <name> is the @Named value of the endpoint.
@Endpoint(url = "https://http2-api.example.com/stream",
method = "GET",
protocol = Protocol.HTTP2)
@Named("http2-api")
private RestClient<Void, StreamResponse> http2Client;Hook into WebClientOptions construction for custom SSL keystores, proxy settings, or other Vert.x options not covered by the annotation model:
public class MyRestClientConfigurator implements RestClientConfigurator {
@Override
public WebClientOptions configure(String endpointName, Endpoint endpoint,
WebClientOptions options) {
if ("secure-api".equals(endpointName)) {
options.setKeyCertOptions(myKeyCert);
options.setProxyOptions(new ProxyOptions()
.setHost("proxy.internal").setPort(3128));
}
return options;
}
}Register via JPMS:
module my.app {
requires com.guicedee.rest.client;
provides com.guicedee.rest.client.RestClientConfigurator
with my.app.MyRestClientConfigurator;
}RestClient instances are bound by RestClientBinder using the full parameterized type and @Named qualifier:
public class OrderService {
@Inject
@Endpoint(url = "https://api.example.com/orders", method = "POST")
@Named("create-order")
private RestClient<OrderRequest, OrderResponse> orderClient;
@Inject
@Endpoint(url = "https://api.example.com/orders/{id}", method = "GET")
@Named("get-order")
private RestClient<Void, OrderResponse> getOrderClient;
public Uni<OrderResponse> createOrder(OrderRequest request) {
return orderClient.send(request);
}
public Uni<OrderResponse> getOrder(String id) {
return getOrderClient.pathParam("id", id).send();
}
}RestClientInjectionPointProvision implements InjectionPointProvider to detect @Endpoint-annotated fields, ensuring Guice creates bindings for all discovered endpoints at startup.
All failures β HTTP errors, deserialization errors, and transport errors β are surfaced as RestClientException:
| Method | Returns true when |
|---|---|
isClientError() |
Status 400β499 |
isServerError() |
Status 500β599 |
isTransportError() |
No HTTP status (connection failure) |
| Getter | Purpose |
|---|---|
getStatusCode() |
HTTP status code (0 if transport error) |
getStatusMessage() |
HTTP status message or error description |
getResponseBody() |
Raw response body (if available) |
IGuiceContext.instance()
ββ IGuicePreStartup hooks
ββ RestClientPreStartup (sortOrder = MIN_VALUE + 100)
ββ RestClientRegistry.scanAndRegisterEndpoints()
ββ ClassGraph scan for @Endpoint-annotated fields
ββ Extract @Named binding name, URL, Send/Receive types
ββ Wrap annotations with environment variable resolution
ββ Populate metadata maps (definitions, URLs, types)
ββ IGuiceModule hooks
ββ RestClientBinder
ββ scanAndRegisterEndpoints() (idempotent guard)
ββ For each endpoint:
β ββ Build Key<RestClient<Send,Receive>> + @Named
β ββ Build WebClientOptions from @EndpointOptions
β ββ Apply RestClientConfigurator SPIs
β ββ Cache WebClient per endpoint name
β ββ bind(key).toProvider(RestClient::new)
ββ InjectionPointProvider detects @Endpoint fields
com.guicedee.rest.client
βββ com.guicedee.client (GuicedEE SPI contracts, Environment)
βββ com.guicedee.vertx (Vert.x lifecycle, VertXPreStartup)
βββ com.guicedee.jsonrepresentation (DefaultObjectMapper binding)
βββ io.vertx.web.client (Vert.x WebClient)
βββ io.vertx.core (Vert.x core, Future)
βββ com.fasterxml.jackson.databind (Jackson ObjectMapper)
βββ io.smallrye.mutiny (Uni reactive type)
βββ com.google.guice (Guice injection)
Module name: com.guicedee.rest.client
The module:
- exports
com.guicedee.rest.client,com.guicedee.rest.client.annotations,com.guicedee.rest.client.implementations - provides
IGuiceModulewithRestClientBinder - provides
IGuicePreStartupwithRestClientPreStartup - provides
InjectionPointProviderwithRestClientInjectionPointProvision - uses
RestClientConfigurator
In non-JPMS environments, META-INF/services discovery still works.
| Class | Package | Role |
|---|---|---|
RestClient |
client |
Injectable REST client β send(), publish(), pathParam(), response deserialization |
RestClient.RequestBuilder |
client |
Fluent builder for path params, headers, and query params |
RestClientException |
client |
Unchecked exception with status code, message, body, and error category helpers |
RestClientConfigurator |
client |
SPI for customizing WebClientOptions per endpoint |
Endpoint |
annotations |
Annotation declaring URL, method, protocol, security, and options |
EndpointSecurity |
annotations |
Nested annotation for authentication (Bearer, Basic, ApiKey) |
EndpointOptions |
annotations |
Nested annotation for timeouts, SSL, pooling, retries |
RestClientRegistry |
implementations |
Static registry β scans @Endpoint fields, stores metadata |
RestClientBinder |
implementations |
Guice module β binds RestClient per @Named endpoint |
RestClientProvider |
implementations |
Guice provider β constructs RestClient from registry metadata |
RestClientPreStartup |
implementations |
Pre-startup hook β triggers classpath scanning |
RestClientInjectionPointProvision |
implementations |
InjectionPointProvider β detects @Endpoint fields for binding |
Issues and pull requests are welcome β please add tests for new authentication strategies, response types, or WebClient options.