Riptide HTTP Client tutorial
Riptide: learning the fundamentals of the open source Zalando HTTP client
Overview
Riptide is a Zalando open source Java HTTP client that implements declarative client-side response routing. It allows dispatching HTTP responses very easily to different handler methods based on various characteristics of the response, including status code, status family, and content type. The way this works is similar to server-side request routing, where any request that reaches a web application is usually routed to the correct handler based on the combination of URI (including query and path parameters), method, Accept and Content-Type header. With Riptide, you can define handler methods on the client side based on the response characteristics. See the concept document for more details. Riptide is part of the core Java/Kotlin stack and is used in production by hundreds of applications at Zalando.
In this tutorial, we'll explore the fundamentals of Riptide HTTP client. We'll learn how to initialize it and examine various use cases: sending simple GET and POST requests, and processing different responses.
Maven Dependencies
First, we need to add the library as a dependency into the pom.xml
file:
<dependency>
<groupId>org.zalando</groupId>
<artifactId>riptide-core</artifactId>
<version>${riptide.version}</version>
</dependency>
Check Maven Central page to see the latest version of the library.
Client Initialization
To send HTTP requests, we need to build an Http
object, then we can use it for all our HTTP requests for the specified base URL:
Http.builder()
.executor(executor)
.requestFactory(new SimpleClientHttpRequestFactory())
.baseUrl(getBaseUrl(server))
.build();
Sending Requests
Sending requests using Riptide is pretty straightforward: you need to use an appropriate method from the created Http
object depending on the HTTP request method. Additionally, you can provide a request body, query params, content type, and request headers.
GET Request
Here is an example of sending a simple GET request:
http.get("/products")
.header("X-Foo", "bar")
.call(pass())
.join();
POST Request
POST requests also can be sent easily:
http.post("/products")
.header("X-Foo", "bar")
.contentType(MediaType.APPLICATION_JSON)
.body("str_1")
.call(pass())
.join();
In the next sections, we will explain the meanings of the call
, pass
, and join
methods from the code snippets above.
Response Routing
One of the main features of the Riptide HTTP client is declarative response routing. We can use the dispatch
method to specify processing logic (routes) for different response types. The dispatch
method accepts the Navigator
object as its first parameter, this parameter specifies which response attribute will be used for the routing logic.
Riptide has several default Navigator
-s:
Navigator | Response characteristic |
---|---|
Navigators.series() | Class of status code |
Navigators.status() | Status |
Navigators.statusCode() | Status code |
Navigators.reasonPhrase() | Reason Phrase |
Navigators.contentType() | Content-Type header |
Simple Routing
Let's see how we can use response routing:
http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, product -> log.info("Product: " + product)),
on(NOT_FOUND).call(response -> log.warn("Product not found")),
anyStatus().call(pass()))
.join();
In this example, we demonstrate retrieving a product by its ID and handling the responses. We use the Navigators.status()
static method to route our responses based on their statuses. We then describe processing logic for different statuses:
OK
- we use a version of thecall
method that deserializes the response body into the specified type (Product
in our case). This deserialized object is then used as a parameter for a consumer, which is passed as a second argument to thecall
method. In our example, the consumer simply logs theProduct
object.NOT_FOUND
- we assume that we won't receive aProduct
response, so we use another version of thecall
method with a single argument: a consumer acceptingorg.springframework.http.client.ClientHttpResponse
. In this scenario, we decide to log a warning message.- All other statuses we intend to process in the same way. To achieve this we use the
Bindings.anyStatus()
static function, allowing us to describe the processing logic for all remaining statuses. In our case, we have decided that no action is required for such statuses, so we utilize thePassRoute.pass()
static method, that returns do-nothing handler.
In Riptide all requests are sent using an Executor
(configured in the executor
method in the Client initialization section). Because of this, responses are always processed in separate threads and the dispatch
method returns CompletableFuture<ClientHttpResponse>
. To make the invoking thread waiting for the response to be processed, we use the join()
method in our example.
Nested Routing
We can have nested (multi-level) routing for our responses. For example, the first level of routing can be based on the response series
, and the second level - on specific status codes:
http.get("/products/{id}", 100)
.dispatch(series(),
on(SUCCESSFUL).call(Product.class, product -> log.info("Product: " + product)),
on(CLIENT_ERROR).dispatch(
status(),
on(NOT_FOUND).call(response -> log.warn("Product not found")),
on(TOO_MANY_REQUESTS).call(response -> {throw new RuntimeException("Too many reservation requests");}),
anyStatus().call(pass())),
on(SERVER_ERROR).call(response -> {throw new RuntimeException("Server error");}),
anySeries().call(pass()))
.join();
In the example above, we implement nested routing. First, we dispatch our responses based on the series
using the static method Navigators.series()
, and then we dispatch CLIENT_ERROR
responses based on their specific statuses. For other series such as SUCCESSFUL
, we utilize a single handler per series without any nested routing.
Similar to the previous example, we use the PassRoute.pass()
static method to skip actions for certain cases. Additionally, we use Bindings.anyStatus()
and Bindings.anySeries()
methods to define default behavior for all series or statuses that are not explicitly described. Furthermore, in this example, we've chosen to throw exceptions for specific cases, these exceptions can be then caught and processed in the invoking code - see TOO_MANY_REQUESTS
status and SERVER_ERROR
series routes.
Returning Response Objects
In some cases we need to return a response object from the REST endpoints invocation - we can use a riptide-capture
module to do so.
Let's take a look on a simple example:
ClientHttpResponse clientHttpResponse = http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, product -> log.info("Product: {}", product)),
anyStatus().call(response -> {throw new RuntimeException("Invalid status");}))
.join();
As mentioned earlier, when we invoke the dispatch
method, it returns a CompletableFuture<ClientHttpResponse>
. If we then invoke the join()
method and wait for the result of invocation - we'll get an object of type ClientHttpResponse
. However, with the assistance of the riptide-capture
module, we can return a deserialized object from the response body instead. In our example, the deserialized object has a type Product
.
First, we need to add a dependency for the riptide-capture
module:
<dependency>
<groupId>org.zalando</groupId>
<artifactId>riptide-capture</artifactId>
<version>${riptide.version}</version>
</dependency>
Now let's rewrite the previous example using the Capture
class. This class allows us to extract a value of a specified type from the response body:
Capture<Product> capture = Capture.empty();
Product product = http.get("/products/{id}", 100)
.dispatch(status(),
on(OK).call(Product.class, capture),
anyStatus().call(response -> {throw new RuntimeException("Invalid status");}))
.thenApply(capture)
.join();
In this example, we pass the capture
object to the route for the OK
status. The purpose of the capture
object is to deserialize the response body into a Product
object and store it for future use. Then we invoke the thenApply(capture)
method to retrieve stored Product
object. The thenApply(capture)
method will return a CompletableFuture<Product>
, so we again can utilize the join()
method to get a Product
object, as we did in the previous examples. See also the riptide-capture module page for more details.
Conclusion
In this article, we've demonstrated the fundamental use cases of the Riptide HTTP client. You can find the code snippets with complete imports on GitHub.
In future articles, we'll explore usage of Riptide plugins - they provide additional logic for your REST client, such as retries, authorization, metrics publishing etc. Additionally, we'll look at Riptide Spring Boot starter, that simplifies an Http
object initialization.
We're hiring! Do you like working in an ever evolving organization such as Zalando? Consider joining our teams as a Software Engineer!