Decorating a client

A 'decorating client' (or a 'decorator') is a client that wraps another client to intercept an outgoing request or an incoming response. As its name says, it is an implementation of the decorator pattern. Client decoration takes a crucial role in Armeria. A lot of core features such as logging, metrics and distributed tracing are implemented as decorators and you will also find it useful when separating concerns.

There are basically two ways to write a decorating client:

Implementing DecoratingHttpClientFunction and DecoratingRpcClientFunction

DecoratingHttpClientFunction and DecoratingRpcClientFunction are functional interfaces that greatly simplify the implementation of a decorating client. They enable you to write a decorating client with a single lambda expression:

import com.linecorp.armeria.common.HttpRequest;
import com.linecorp.armeria.common.HttpResponse;

ClientBuilder cb = Clients.builder(...);
...
cb.decorator((delegate, ctx, req) -> {
    auditRequest(req);
    return delegate.execute(ctx, req);
});

MyService.Iface client = cb.build(MyService.Iface.class);

Extending SimpleDecoratingHttpClient and SimpleDecoratingRpcClient

If your decorator is expected to be reusable, it is recommended to define a new top-level class that extends SimpleDecoratingHttpClient or SimpleDecoratingRpcClient depending on whether you are decorating an HttpClient or an RpcClient:

import com.linecorp.armeria.client.HttpClient;
import com.linecorp.armeria.client.SimpleDecoratingHttpClient;

public class AuditClient extends SimpleDecoratingHttpClient {
    public AuditClient(HttpClient delegate) {
        super(delegate);
    }

    @Override
    public HttpResponse execute(ClientRequestContext ctx, HttpRequest req) throws Exception {
        auditRequest(req);
        return unwrap().execute(ctx, req);
    }
}

ClientBuilder cb = Clients.builder(...);
...
// Using a lambda expression:
cb.decorator(delegate -> new AuditClient(delegate));

The order of decoration

The decorators are executed in reverse order of the insertion. The following example shows which order the decorators are executed by printing the messages.

import com.linecorp.armeria.client.WebClient;

ClientBuilder cb = Clients.builder(...);

// #2 decorator
cb.decorator((delegate, ctx, req) -> {
    // This is called from #1 decorator.
    System.err.println("Secondly, executed.");
    return delegate.execute(ctx, req);
});

// #1 decorator.
// No matter decorator() or option() is used, decorators are executed in reverse order of the insertion.
cb.option(ClientOption.DECORATION, ClientDecoration.of((delegate, ctx, req) -> {
    System.err.println("Firstly, executed");
    return delegate.execute(ctx, req); // The delegate, here, is #2 decorator.
                                       // This will call execute(ctx, req) method on #2 decorator.
});

WebClient myClient = cb.build(WebClient.class);

The following diagram describes how an HTTP request and HTTP response are gone through decorators:

If the client is a Thrift client and RPC decorators are used, HTTP decorators and RPC decorators are separately grouped and executed in reverse order of the insertion:

ClientBuilder cb = Clients.builder(...);

// #2 HTTP decorator.
cb.decorator((delegate, ctx, httpReq) -> {
    System.err.println("Fourthly, executed.");
    ...
});

// #2 RPC decorator.
cb.rpcDecorator((delegate, ctx, rpcReq) -> {
    System.err.println("Secondly, executed.");
    ...
});

// #1 HTTP decorator.
cb.decorator((delegate, ctx, httpReq) -> {
    System.err.println("Thirdly, executed.");
    ...
});

// #1 RPC decorator.
cb.rpcDecorator((delegate, ctx, rpcReq) -> {
    System.err.println("Firstly, executed.");
    ...
});

An RPC request is converted into an HTTP request before it's sent to a server. Therefore, RPC decorators are applied before the RPC request is converted and HTTP decorators are applied after the request is converted into the HTTP request, as described in the following diagram:

If the decorator modifies the response (e.g. DecodingClient) or spawns more requests (e.g. RetryingClient), the outcome may be different depending on the order of the decorators. Let's look at the following example that DecodingClient and ContentPreviewingClient are used together:

import com.linecorp.armeria.client.encoding.DecodingClient;
import com.linecorp.armeria.client.logging.ContentPreviewingClient;

ClientBuilder cb = Clients.builder(...);
cb.decorator(DecodingClient.newDecorator());

// ContentPreviewingClient should be applied after DecodingClient.
cb.decorator(ContentPreviewingClient.newDecorator(1000));

DecodingClient decodes the content of HTTP responses. ContentPreviewingClient is Enabling content previews of the HTTP response by setting it to the RequestLog. Because it's evaluated after DecodingClient from the point of view of an HTTP response, which means that the response content is set after it's decoded, you will see the decoded response content preview. If the two decorators are added in the opposite order, you will get the encoded preview because ContentPreviewingClient is evaluated first for the HTTP response. For RetryingClient, please check out RetryingClient with logging.

Modifying responses

We can modify the responses in a decorator via the HttpResponse.mapHeaders(), HttpResponse.mapData(), HttpResponse.mapInformational() or mapTrailers(Function) methods, or, if more advanced/stateful processing is necessary, by wrapping the HttpResponse in a FilteredHttpResponse.

Using transforming methods

import com.linecorp.armeria.client.ClientBuilder;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.HttpData;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.concurrent.atomic.AtomicBoolean;

ClientBuilder cb = Clients.builder("http");
cb.decorator((delegate, ctx, req) -> {
    final AtomicBoolean initialHttpData = new AtomicBoolean(true);
    return delegate
        .execute(ctx, req)
        .mapHeaders(headers -> headers.toBuilder().add("x-foo", "foobar").build())
        .mapTrailers(trailers -> ...)
        .mapInformational(informationalHeader -> ...)
        .mapData((httpData) -> {
            HttpData result = httpData;
            if (initialHttpData.get()) {
                initialHttpData.set(false);
                byte[] ascii = "{\"foo\": \"foobar\",".getBytes(StandardCharsets.US_ASCII);
                byte[] combined = Arrays.copyOf(ascii, ascii.length + httpData.length() - 1);
                System.arraycopy(httpData.array(), 1, combined, ascii.length, httpData.length());
                result = HttpData.wrap(combined);
            }
            return result;
        });
});

Using FilteredHttpResponse:

A couple of caveats to note here.

  • Informational response headers may occur zero or more times.
  • HttpData will almost always just be a part of the whole response data unless the response has been passed through HttpResponse.aggregate().
import com.linecorp.armeria.client.ClientBuilder;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.FilteredHttpResponse;
import com.linecorp.armeria.common.HttpData;
import com.linecorp.armeria.common.HttpHeaders;
import com.linecorp.armeria.common.HttpObject;
import com.linecorp.armeria.common.HttpResponse;
import com.linecorp.armeria.common.ResponseHeaders;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;

ClientBuilder cb = Clients.builder(...);
cb.decorator((delegate, ctx, req) -> {
    HttpResponse response = delegate.execute(ctx, req);
    return new FilteredHttpResponse(response) {
        boolean initialHttpData = true;
        protected HttpObject filter(HttpObject obj) {
            if (obj instanceof ResponseHeaders) {
                // Can receive multiple ResponseHeaders whose status is informational.
                ResponseHeaders responseHeaders = (ResponseHeaders) obj;
                if (responseHeaders.status().isInformational()) {
                    return obj;
                }
                // Add a "x-foo" header into the response.
                return responseHeaders.toBuilder()
                                      .add("x-foo", "foobar")
                                      .build();
            } else if (obj instanceof HttpData) {
                HttpData httpData = (HttpData) obj;
                // Example of how to insert attribute into json payload.
                // initial '{' in response is assumed to be first byte
                if (initialHttpData) {
                    initialHttpData = false;
                    byte[] ascii = "{\"foo\": \"foobar\",".getBytes(StandardCharsets.US_ASCII);
                    byte[] combined = Arrays.copyOf(ascii, ascii.length +  httpData.length() - 1);
                    System.arraycopy(httpData.array(), 1, combined, ascii.length, httpData.length());
                    return HttpData.wrap(combined);
                }
            } else {
                // Http trailers.
                assert obj instanceof HttpHeaders;
            }
            return obj;
        }
    };
});

Reacting to or logging responses

If modification is not required, e.g. when handling quota responses from the service, and it doesn't need to look at the HttpData, it can be achieved in a more performant way via ClientRequestContext.log().whenAvailable(RequestLogProperty).thenAccept(...). Accessing the ClientRequestContext can be done via either wrapping in a decorator (and using the ctx or by using ClientRequestContextCaptor:

import com.linecorp.armeria.client.ClientRequestContextCaptor;
import com.linecorp.armeria.client.Clients;
import com.linecorp.armeria.common.logging.RequestLogProperty;

HttpResponse res;
try (ClientRequestContextCaptor captor = Clients.newContextCaptor()) {
    res = webClient.get(path);
    captor.get().log().whenAvailable(RequestLogProperty.RESPONSE_HEADERS)
          .thenAccept(log -> {
              System.err.println(log.responseHeaders());
          });
}

See Structured Logging to learn more about the properties you can retrieve.

See also