7.3 Declarative HTTP Clients with @Client
Now that you have an understanding of the workings of the lower-level HTTP client, let’s take a look at Micronaut’s support for declarative clients via the Client annotation.
Essentially, the @Client
annotation can be declared on any interface or abstract class, and through the use of Introduction Advice the abstract methods are implemented for you at compile time, greatly simplifying the creation of HTTP clients.
Let’s start with a simple example. Given the following class:
Pet.java
public class Pet {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
Pet.java
class Pet {
String name
int age
}
Pet.java
class Pet {
var name: String? = null
var age: Int = 0
}
You can define a common interface for saving new Pet
instances:
PetOperations.java
import io.micronaut.http.annotation.Post;
import io.micronaut.validation.Validated;
import org.reactivestreams.Publisher;
import io.micronaut.core.async.annotation.SingleResult;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
@Validated
public interface PetOperations {
@Post
@SingleResult
Publisher<Pet> save(@NotBlank String name, @Min(1L) int age);
}
PetOperations.java
import io.micronaut.http.annotation.Post
import io.micronaut.validation.Validated
import org.reactivestreams.Publisher
import io.micronaut.core.async.annotation.SingleResult
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
@Validated
interface PetOperations {
@Post
@SingleResult
Publisher<Pet> save(@NotBlank String name, @Min(1L) int age)
}
PetOperations.java
import io.micronaut.http.annotation.Post
import io.micronaut.validation.Validated
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
import io.micronaut.core.async.annotation.SingleResult
import org.reactivestreams.Publisher
@Validated
interface PetOperations {
@Post
@SingleResult
fun save(@NotBlank name: String, @Min(1L) age: Int): Publisher<Pet>
}
Note how the interface uses Micronaut’s HTTP annotations which are usable on both the server and client side. You can also use javax.validation
constraints to validate arguments.
Be aware that some annotations, such as Produces and Consumes, have different semantics between server and client side usage. For example, @Produces on a controller method (server side) indicates how the method’s return value is formatted, while @Produces on a client indicates how the method’s parameters are formatted when sent to the server. While this may seem a little confusing, it is logical considering the different semantics between a server producing/consuming vs a client: a server consumes an argument and returns a response to the client, whereas a client consumes an argument and sends output to a server. |
Additionally, to use the javax.validation
features, add the validation
module to your build:
implementation("io.micronaut:micronaut-validator")
<dependency>
<groupId>io.micronaut</groupId>
<artifactId>micronaut-validator</artifactId>
</dependency>
On the server-side of Micronaut you can implement the PetOperations
interface:
PetController.java
import io.micronaut.http.annotation.Controller;
import org.reactivestreams.Publisher;
import reactor.core.publisher.Mono;
import io.micronaut.core.async.annotation.SingleResult;
@Controller("/pets")
public class PetController implements PetOperations {
@Override
@SingleResult
public Publisher<Pet> save(String name, int age) {
Pet pet = new Pet();
pet.setName(name);
pet.setAge(age);
// save to database or something
return Mono.just(pet);
}
}
PetController.java
import io.micronaut.http.annotation.Controller
import org.reactivestreams.Publisher
import io.micronaut.core.async.annotation.SingleResult
import reactor.core.publisher.Mono
@Controller("/pets")
class PetController implements PetOperations {
@Override
@SingleResult
Publisher<Pet> save(String name, int age) {
Pet pet = new Pet(name: name, age: age)
// save to database or something
return Mono.just(pet)
}
}
PetController.java
import io.micronaut.http.annotation.Controller
import reactor.core.publisher.Mono
import io.micronaut.core.async.annotation.SingleResult
import org.reactivestreams.Publisher
@Controller("/pets")
open class PetController : PetOperations {
@SingleResult
override fun save(name: String, age: Int): Publisher<Pet> {
val pet = Pet()
pet.name = name
pet.age = age
// save to database or something
return Mono.just(pet)
}
}
You can then define a declarative client in src/test/java
that uses @Client
to automatically implement a client at compile time:
PetClient.java
import io.micronaut.http.client.annotation.Client;
import org.reactivestreams.Publisher;
import io.micronaut.core.async.annotation.SingleResult;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
@Client("/pets") (1)
public interface PetClient extends PetOperations { (2)
@Override
@SingleResult
Publisher<Pet> save(@NotBlank String name, @Min(1L) int age); (3)
}
PetClient.java
import io.micronaut.http.client.annotation.Client
import org.reactivestreams.Publisher
import io.micronaut.core.async.annotation.SingleResult
@Client("/pets") (1)
interface PetClient extends PetOperations { (2)
@Override
@SingleResult
Publisher<Pet> save(String name, int age) (3)
}
PetClient.java
import io.micronaut.http.client.annotation.Client
import io.micronaut.core.async.annotation.SingleResult
import org.reactivestreams.Publisher
@Client("/pets") (1)
interface PetClient : PetOperations { (2)
@SingleResult
override fun save(name: String, age: Int): Publisher<Pet> (3)
}
1 | The Client annotation is used with a value relative to the current server, in this case /pets |
2 | The interface extends from PetOperations |
3 | The save method is overridden. See the warning below. |
Notice in the above example we override the save method. This is necessary if you compile without the -parameters option since Java does not retain parameters names in bytecode otherwise. Overriding is not necessary if you compile with -parameters . In addition, when overriding methods you should ensure any validation annotations are declared again since these are not Inherited annotations. |
Once you have defined a client you can @Inject
it wherever you need it.
Recall that the value of @Client
can be:
An absolute URI, e.g.
[https://api.twitter.com/1.1](https://api.twitter.com/1.1)
A relative URI, in which case the server targeted is the current server (useful for testing)
A service identifier. See the section on Service Discovery for more information on this topic.
In production you typically use a service ID and Service Discovery to discover services automatically.
Another important thing to notice regarding the save
method in the example above is that it returns a Single type.
This is a non-blocking reactive type - typically you want your HTTP clients to not block. There are cases where you may want an HTTP client that does block (such as in unit tests), but this is rare.
The following table illustrates common return types usable with @Client:
Type | Description | Example Signature |
---|---|---|
Any type that implements the Publisher interface |
| |
An HttpResponse and optional response body type |
| |
A Publisher implementation that emits a POJO |
| |
A Java |
| |
A blocking native type. Such as |
| |
T | Any simple POJO type. |
|
Generally, any reactive type that can be converted to the Publisher interface is supported as a return type, including (but not limited to) the reactive types defined by RxJava 1.x, RxJava 2.x, and Reactor 3.x.
Returning CompletableFuture instances is also supported. Note that returning any other type results in a blocking request and is not recommended other than for testing.