Riptide HTTP Client tutorial

Riptide: learning the fundamentals of the open source Zalando HTTP client

photo of Olga Semernitskaia
Olga Semernitskaia

Engineering Lead

Posted on Jun 29, 2023

Riptide logo - big ocean wave

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:

NavigatorResponse 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 the call 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 the call method. In our example, the consumer simply logs the Product object.
  • NOT_FOUND - we assume that we won't receive a Product response, so we use another version of the call method with a single argument: a consumer accepting org.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 the PassRoute.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! Join one of our Software Engineering teams at Zalando.



Related posts