Mktd#1 Feign Vs Retrofit : 2 Going Further
This article is the second of the series REST clients in Java.
Previous article: MKTD#1 : Getting started
Challenge #2: Going further...
The second challenge aims at solving advanced problems such as:
- Authentication and session management
- Errors management via Java
Exception
- Files upload and download
Session management using Cookies
Out of the box, the server provides authentication and session management mechanisms using JWT and Cookies. A new Java interface describes this operation:
public interface AuthenticationApi {
String login(LoginPassword loginPassword) throws SecurityException;
}
The response HTTP may return a Set-Cookie
header to be decoded. This cookie value will then be added to subsequent requests headers sent to other services.
Token based solutions are simpler to put in place using Feign and Retrofit, but in our scenario we are not trying to follow the simplest approach. You can find more information on this topic on this blog.
Errors management
Regarding errors management, we need to return specific errors based on the HTTP code returned by the server. The following piece of code handles the HTTP error to Java exception mapping:
public static RuntimeException decodeError(int status, String message, Supplier<RuntimeException> defaultCase) {
switch (status) {
case 404: // Not Found
return new NoSuchElementException(message);
case 400: // Bad Request
return new IllegalArgumentException(message);
case 401: // Unauthorized
case 403: // Forbidden
return new SecurityException(message);
default:
return defaultCase.get();
}
}
Feign
Pro tip: HTTP requests logging
To ease this challenge implementation, it appears to be very handy to log the HTTP requests and responses.
Quick & Dirty way
The quickest way to achieve this is to use a RequestInterceptor
called by Feign before an HTTP request gets built and log it via System.out
.
Feign.builder()
.interceptor(System.out::println) // Quick & Dirty debug
.decoder(new GsonDecoder())
.encoder(new GsonEncoder())
.target(MonkeyRaceApi.class, url);
This obviously works only for HTTP requests, to achieve the same for the responses, similar thing has to be added to the decoder.
Using Feign logger
To avoid third-parties libraries dependencies, Feign defines its own Logger
. It consists of an abstract class with a single method to implement.
The log level needs to be defined to allow log filtering later on (NONE
, BASIC
, HEADERS
, FULL
).
Here is an example:
Feign.builder()
.logLevel(Logger.Level.FULL)
.logger(new Logger() {
@Override
protected void log(String configKey, String format, Object... args) {
System.out.printf("[%s] ", configKey);
System.out.printf(format, args);
System.out.println();
}
})
.decoder(new GsonDecoder())
.encoder(new GsonEncoder())
.target(MonkeyRaceApi.class, url);
By default, Feign logger class feign.Logger.JavaLogger
relies on the JDK logger java.util.logging.Logger
but an extension exists to use other loggers such as SLF4J.
Cookie based Authentication
The first step consists in retrieving the cookie generated by the authentication request. To do so, a specific decoder to handle the response headers and store them as cookies is used.
private static String getAuthToken(String url, String login, String password) {
AuthenticationApi authenticationApi = builder
.encoder(new GsonEncoder())
.decoder((response, type) -> handleCookies(response.headers())) // decode cookies
.target(AuthenticationApi.class, url);
return authenticationApi.login(new LoginPassword(login, password));
}
The cookie storage is managed by the CookieManager available since Java6.
private static final CookieManager COOKIE_MANAGER = new CookieManager();
The usage of this CookieManager
is handled from the method handleCookies
as follows:
private static String handleCookies(Map<String, Collection<String>> headers) {
// From Map<String, Collection<String>> to Map<String, List<String>>
Map<String, List<String>> h = headers.entrySet().stream()
.collect(
toMap(
Map.Entry::getKey,
entry -> entry.getValue().stream().collect(toList()))
);
try {
URI uri = URI.create(BASE_URL);image
COOKIE_MANAGER.put(uri, h);
return COOKIE_MANAGER.getCookieStore().get(uri).stream() // Stream<HttpCookie>
.filter(cookie -> "token".equals(cookie.getName()))
.findFirst() // Optional<HttpCookie>
.map(HttpCookie::getValue)
.orElseThrow(() -> new IllegalStateException("Authentication cookie not found"));
} catch (IOException e) {
throw new RuntimeException(e);
}
}
After being stored, this cookie is automatically sent back in subsequent requests.
This is achieved by using the RequestInterceptor
mechanism that modifies the RequestTemplate
. Feign uses this object to construct the HTTP request.
static MonkeyRaceApi buildRaceApi(String url, String login, String password) {
getAuthToken(url, login, password);
return builder
.requestInterceptor(ApiFactory::addCookies) // Inject Cookies
.decoder(new GsonDecoder())
.encoder(new GsonEncoder())
.target(MonkeyRaceApi.class, url);
}
The interceptor behaves as a RequestTemplate
consumer:
private static void addCookies(RequestTemplate template) {
URI uri = URI.create(BASE_URL);
COOKIE_MANAGER.getCookieStore().get(uri).stream()
.map(HttpCookie::toString)
.forEach(cookie -> template.header("Cookie", cookie));
}
With Feign, the cookie based authentication is closed to the Authorization
header mechanism often associated to JWT. As it is based on HTTP headers, the same implementation approach using a RequestInterceptor
can be used to handle authentication via token. On the other hand, using cookies makes the code more complex and impacts its readability.
We cam also use feign.Target
to manage the authentication, see Feign documentation.
Errors management
Feign proposes a specific errors management mechanism out of the box - if an HTTP response code >= 400. This is done using the ErrorDecoder
:
static MonkeyRaceApi buildRaceApi(String url, String login, String password) {
getAuthToken(url, login, password);
return builder
.errorDecoder(ApiFactory::decodeError) // Decode errors
.requestInterceptor(ApiFactory::addCookies) // Inject Cookies
.decoder(new GsonDecoder())
.encoder(new GsonEncoder())
.target(MonkeyRaceApi.class, url);
}
private static Exception decodeError(String methodKey, Response response) {
return decodeError(response.status(), methodKey,
() -> FeignException.errorStatus(methodKey, response));
}
Sometimes it can be useful to have a custom management of 404 errors. The Decoder
method feign.Feign.Builder#decode404
can be used to define the default behavior.
Upload
File upload can be achieved in two different ways on the REST server:
- Direct upload with the file content inside the request body and the request header
Content-type
set toapplication/octet-stream
.
The two approaches can be implemented using the same principle: a specific Decoder
.
The second option is easier to set up as managing the multipart request body requires the usage of an external API such as Apache Commons FileUpload
Other options are also available https://github.com/xxlabaza/feign-form or https://github.com/pcan/feign-client-test to find implementation examples of the multipart approach.
So the second solution is much simpler, the main idea is to handle the specific case of objects of type java.io.InputStream
and to delegate other cases to a traditional JSON encoder. To define the HTTP request body, the method feign.RequestTemplate#body(byte[], java.nio.charset.Charset)
needs to be called.
It is possible to write the encoder body inside a lambda with Java 8, but it would be preferable to extract this implementation in a new class:
public class UploadEncoder implements Encoder {
private final Encoder delegate;
public UploadEncoder(Encoder encoder) {
super();
delegate = encoder;
}
@Override
public void encode(Object object, Type bodyType, RequestTemplate template) throws EncodeException {
if (InputStream.class.equals(bodyType)) {
template.header("Content-type", "application/octet-stream");
InputStream inputStream = InputStream.class.cast(object);
// InputStream to byte[]
try (BufferedInputStream bin = new BufferedInputStream(inputStream);
ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
byte[] buffer = new byte[1024];
int bytesRead;
while ((bytesRead = bin.read(buffer)) > 0) {
bos.write(buffer, 0, bytesRead);
}
bos.flush();
template.body(bos.toByteArray(), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new EncodeException("Cannot upload file", e);
}
} else {
delegate.encode(object, bodyType, template);
}
}
}
It is obviously possible to simplify this code by using a library to handle to conversion of InputStream
into byte[]
, but it does not hurt to write try with resources
from time to time.It would be fair to say that this code may cause issues when working with large files. But in this scenario, we also need to ask the question: is a REST API the right solution to upload large files?
Download
We use feign.Decoder
to download files the same way we did for the upload:
public class DownloadDecoder implements Decoder {
private final Decoder delegate;
DownloadDecoder(Decoder decoder) {
super();
delegate = decoder;
}
@Override
public Object decode(Response response, Type type) throws IOException, DecodeException, FeignException {
if (InputStream.class.equals(type)) {
return response.body().asInputStream();
}
return delegate.decode(response, type);
}
}
Once again, Feign makes this operation quite simple as soon as the encoders/decoders are correctly used.
Summary
Feign makes the handle of HTTP headers really simple, thus simplifying the authentication mechanisms using cookies or tokens.
Errors management with Feign is also trivial which is one of the key benefits over the usage of Retrofit.
Encoding mechanism provides an easy way to handle file upload and more generally to manage all the scenarios of requests serialising. The decoding mechanism allows retrieving the content of a file that is downloaded and more generally deserialising HTTP responses.
Feign comes with a lot of flexibility using the Encoder
, Decoder
, RequestInterceptor
, ... and we can easily solve common issues we face with REST APIs.
Retrofit
Cookie based Authentication
The easiest way to manage authentication with Retrofit is to use the internal of the HTTP client (okHttp3
). Similarly to Feign we send an initial request to fetch the cookie and then reuse the same client for all subsequent requests.
Another option consists in using different clients for each requests but reusing the same cookieJar
.
Here is a first basic implementation to understand the principle of a cookieJar
:
client = new OkHttpClient.Builder()
.cookieJar(
new CookieJar() {
private final HashMap<String, List<Cookie>> cookieStore = new HashMap<>();
@Override
public void saveFromResponse(HttpUrl url, List<Cookie> cookies) {
cookieStore.put(url.uri().getAuthority(), cookies);
}
@Override
public List<Cookie> loadForRequest(HttpUrl url) {
List<Cookie> cookies = cookieStore.get(url.uri().getAuthority());
return cookies != null ? cookies : new ArrayList<Cookie>();
}
});
A more elegant implementation consists in adding the dependency okhttp-urlconnection
from OkHttp
.
CookieHandler cookieHandler = new CookieManager(
new PersistentCookieStore(ctx), CookiePolicy.ACCEPT_ALL);
OkHttpClient httpClient = new OkHttpClient.Builder()
.cookieJar(new JavaNetCookieJar(cookieHandler));
Errors management
There is no, from my point of view, any ideal solution with Retrofit to manage errors the way it is done with Feign.
In this example, we use the functionality of the OkHttp
interceptor. There is still a limitation, the exceptions returned must be of type RuntimeException
.
OkHttpClient client = new OkHttpClient.Builder()
.addInterceptor(this::authInterceptor);
private Response authInterceptor(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
Response response = chain.proceed(request);
if (!response.isSuccessful()) {
RuntimeException ex = ApiFactory.decodeError(response.code(), response.message(), () -> null);
if (ex != null) {
throw ex;
}
}
return response;
}
Upload
To implement the upload, we have decided to use the method multipart form with the header Content-type
set to multipart/form-data
Here is the implementation detail:
The method is added to the service
public interface MonkeyService {
@Multipart
@POST("monkeys/{id}/photo")
Call<Photo> sendPhoto(@Path("id") String id, @Part MultipartBody.Part file);
}
We create a temporary file containing the picture content, then we call the method sendPhoto
giving it a RequestBody
containing the temporary file to then create the MultipartBody
passed down to the service.
@Override
public Photo savePhoto(String id, InputStream stream) throws SecurityException, IllegalArgumentException {
return executeCall(() -> {
try {
Path tmp = Files.createTempFile("exo2", "upload");
Files.copy(stream, tmp, REPLACE_EXISTING);
RequestBody requestFile = RequestBody.create(MediaType.parse("multipart/form-data"), tmp.toFile());
MultipartBody.Part body = MultipartBody.Part.createFormData("photo", tmp.toFile().getName(), requestFile);
return monkeyService.sendPhoto(id, body);
} catch (IOException e) {
e.printStackTrace();
return null;
}
});
}
Download
Regarding the download, we only need to call our service by retrieving the response body. To do so, Retrofit proposes a generic type ResponseBody
to access the raw response.
We start by adding this method to our service:
public interface MonkeyService {
@GET("monkeys/{id}/photo")
Call<ResponseBody> downloadPhoto(@Path("id") String id) throws SecurityException, IllegalArgumentException;
}
then here is the code to fetch the picture:
@Override
public InputStream downloadPhoto(String id) throws SecurityException, IllegalArgumentException {
try {
Response<ResponseBody> response = monkeyService.downloadPhoto(id).execute();
return response.body().byteStream();
} catch (IOException e) {
return null;
}
}
Summary
It is really simple with Retrofit to manage authentication via cookies and tokens as well as the files upload and download.
On the other side, errors management is far less intuitive. Maybe a better approach exists but we haven't found it yet.
Overall Summary
Whether we used Feign or Retrofit, the management of cookies and files upload/download is easily implemented.
We also found that synchronous errors management is better managed with Feign than Retrofit.
Implementations using OkHttp are common to Retrofit and Feign when the client okhttp-client is used in Feign.