Dio Interceptors in Flutter
Sometimes we don’t need complex apps, sometimes we just need an app that displays a list of items from one endpoint, and we can manage to do it with a simple method:
Future<Response> fetchItems() {
return Dio().("https://some-website.com/listing");
}
And we don’t have any errors, there’s no need to log our responses. What about cache? Nobody cares about it!
But truth be told, almost no apps are as simple as this one. Some apps do require more in order for us to get the response from our request, such as:
- Sending dynamic headers to the server, such as a stored key from the shared preferences or the time of the request;
- Checking each response header and saving its values locally;
- Verifying the errors sent from the server and directly map them to Error classes that our app understands;
- Adding a simplified cache to our app so that if the connection is timed out or if the user does not have internet access, we can display the previous response for that request;
- Additionally, we might want to add logging to all our responses and requests.
Interceptors will help us handle this by giving us specific callbacks for errors, requests and response.
Before diving into how we can use interceptors, let’s take a step back and look at how we can configure Dio.
Dio Configuration
Dio can be configured with a BaseOption object that let us initialise a new Dio instance with a set of rules: connectTimeout
, receiveTimeout
and baseUrl
that will be used for every API call we make.
Dio createDio() {
return Dio(
BaseOptions(
connectTimeout: 5000,
receiveTimeout: 5000,
baseUrl: "https://some-website.com"
)
);
}
However, one thing that we cannot add in the base configurations (at the time the article was written) is interceptors. For that, we need to create the new Dio instance and add the interceptors that we want in the interceptors
list.
Dio addInterceptors(Dio dio) {
return dio..interceptors.add(InterceptorsWrapper(
onRequest: (RequestOptions options) => requestInterceptor(options),
onResponse: (Response response) => responseInterceptor(response),
onError: (DioError dioError) => errorInterceptor(dioError)));
}
And with this, we have setup a Dio instance that can be used for any API call that we make.
Adding dynamic headers
As stated in the introduction, let us suppose that the app that we are making needs a header that contains a value stored in the shared preferences. Additionally, that value must have the timestamp and can change at any time.
/As a side note/: Since the data can be changed, we cannot use the BaseOptions
’s extra
field, which would conveniently let us access data static data that we passed to it on its creation. So, we will need to access the shared preferences each time we are making a request.
The InterceptorsWrapper
gives us the RequestOptions
object which has the following properties:
- Request
dynamic data
- Url
String path
- Query Parameters
Map<String, dynamic> queryParameters
With this information, we can start implementing our requestInterceptor
method.
This method returns a dynamic
type that can be:
- The
RequestOptions
object if we want to continue with the request - A
Response
object if we want the app to take care of the request by itself - a
DioError
ordio.reject
object, that will throw an error.
This will let us have the flexibility to validate each request before it’s being made, add data, and throw any error if necessary. For our case, we just need to add some data and proceed with the request.
dynamic requestInterceptor(RequestOptions options) async {
SharedPreferences prefs = await SharedPreferences.getInstance();
var token = prefs.get("token");
options.headers.addAll({"Token": "$token${DateTime.now()}"});
return options;
}
Knock, knock the Project Manager comes to tell you that now, for some reason, you cannot send the headers for a specific set of endpoints.
We could quickly solve this by using a switch
statement with the path
parameter. But we don’t want that. We want to be able to see in the endpoint declaration if that endpoints needs, or does not need the token. So, instead of searching for the paths in the interceptor, we are going to add an auxiliary header
to each endpoint request.
Future<Response> getListOfTodos() {
return dio.get("/todos/1", options: Options(headers: {"requiresToken" : true}));
}
Then, we will be able to verify if the request has that header, remove it, and add the token.
dynamic requestInterceptor(RequestOptions options) async {
if (options.headers.containsKey("requiresToken")) {
//remove the auxiliary header
options.headers.remove("requiresToken");
SharedPreferences prefs = await SharedPreferences.getInstance();
var header = prefs.get("Header");
options.headers.addAll({"Header": "$header${DateTime.now()}"});
return options;
}
}
Verifying the Response
As it was with the Request
, we also have a specific key stored in our shared preferences with which we must verify our responses. If we cannot find the specified header or if the key is different than the one stored, we throw an error message saying that the user is no longer active.
In the same way that we created a method for the request, we can do the same for the response. The key difference is that now we are dealing with a Response
object which in part has the same data as the Request
such as data
, headers
but it also has the statusCode
and the original Request
data.
As with the response
, we have a dynamic
type as a return for this function which can be:
- The
Response
object if we want to continue with the request - A
DioError
if we to throw an error after validating the response data
Which means that, if the boolean value for the header isUserActive
is false, we can return a DioError
object as follows:
dynamic responseInterceptor(Response options) async {
if (options.headers.value("verifyToken") != null) {
//if the header is present, then compare it with the Shared Prefs key
SharedPreferences prefs = await SharedPreferences.getInstance();
var verifyToken = prefs.get("VerifyToken");
// if the value is the same as the header, continue with the request
if (options.headers.value("verifyToken") == verifyToken) {
return options;
}
}
return DioError(request: options.request, message: "User is no longer active");
}
Verifying Errors from the Server
Let us assume that we have some mechanism in our server that can delete a user account completely. In this case, the app has to return to the login screen in order for the user to create a new account. The error from the server has the following message: {“error”:”ERROR_001”}
, and as with the Response
and Request
, we will create an interceptor in order to catch all the incoming errors.
Looking at the documentation, we can see that the type for the error interceptor is also dynamic
with the following specification:
- If we want to continue the request with an error, we return the
DioError
object. - If we want to resolve the request and return a
Response
object, we can do it, in this case, our app is not aware that there has been an error with the server, and continues with the request normally.
To go to a different screen, since we are dealing with the first version of the app (read: something to be refactored later on), we directly navigate the user to another screen from this class using the Navigator widget with a GlobalKey.
dynamic errorInterceptor(DioError dioError) {
if (dioError.message.contains("ERROR_001")) {
// this will push a new route and remove all the routes that were present
navigatorKey.currentState.pushNamedAndRemoveUntil(
"/login", (Route<dynamic> route) => false);
}
return dioError;
}
Creating an Interceptor Class
Creating a function for each interceptor can be an acceptable approach, but what if we have more than one response interceptor? One approach that we can have is to create a class that extends the Interceptor
class and overrides the onRequest
, onError
and onResponse
methods.
class AppInterceptors extends Interceptor {
@override
FutureOr<dynamic> onRequest(RequestOptions options) async {
if (options.headers.containsKey("requiresToken")) {
//remove the auxiliary header
options.headers.remove("requiresToken");
SharedPreferences prefs = await SharedPreferences.getInstance();
var header = prefs.get("Header");
options.headers.addAll({"Header": "$header${DateTime.now()}"});
return options;
}
}
@override
FutureOr<dynamic> onError(DioError dioError) {
if (dioError.message.contains("ERROR_001")) {
// this will push a new route and remove all the routes that were present
navigatorKey.currentState.pushNamedAndRemoveUntil(
"/login", (Route<dynamic> route) => false);
}
return dioError;
}
@override
FutureOr<dynamic> onResponse(Response options) async {
if (options.headers.value("verifyToken") != null) {
//if the header is present, then compare it with the Shared Prefs key
SharedPreferences prefs = await SharedPreferences.getInstance();
var verifyToken = prefs.get("VerifyToken");
// if the value is the same as the header, continue with the request
if (options.headers.value("verifyToken") == verifyToken) {
return options;
}
}
return DioError(request: options.request, message: "User is no longer active");
}
}
This class can then be easily added to the Dio object interceptors:
Dio addInterceptors(Dio dio) {
dio.interceptors.add(AppInterceptors());
}
Putting it all together - Creating a simple Cache
One thing we can do with the interceptors is to create a simple cache for our requests. The concept is simple: if the user is using the app normally and for some reason there’s a network error or a connection-timeout, then we still want the user to see the feed or home page of the app. To do that, we have to save in memory all the requests that we are making, and, when verifying for the connection timeout error or an internal error from Dio (DioErrorType.DEFAULT
), if there’s a request that we have saved for the same endpoint, with the same parameters, then we return the old response and warn the user that he’s offline at the moment.
import 'package:dio/dio.dart';
class CacheInterceptor extends Interceptor {
CacheInterceptor();
var _cache = new Map<Uri, Response>();
@override
onRequest(RequestOptions options) {
return options;
}
@override
onResponse(Response response) {
_cache[response.request.uri] = response;
}
@override
onError(DioError e) {
print('onError: $e');
if (e.type == DioErrorType.CONNECT_TIMEOUT || e.type == DioErrorType.DEFAULT) {
var cachedResponse = _cache[e.request.uri];
if (cachedResponse != null) {
return cachedResponse;
}
}
return e;
}
}
We could further improve this cache by modifying our response and give it a parameter to warn the UI that this response is now a cache, or implement a persistent cache with SQL that would let the user see the previous feed if he opens the app offline, much like what the Linkedin app does.
Bonus Tip - Logging Everything Dio
For the majority of the time spent creating a project, we are going to stumble on errors upon errors when making API requests. Maybe we forgot a query parameter, or the header, or the body has a missing parameter or it’s just a case of the backend having a nasty bug. For all these cases, the best would be to have all the requests logged into our console so that we can easily check what has happened.
As with before, we create a new Interceptor
class, implement all the necessary methods and log all the information that we want from the requests. Furthermore, since we want our logs to easily stand out, we might want to format them to always start and end with the following:
# REQUEST:
--> GET https://jsonplaceholder.typicode.com/todos/1
...
--> END GET
# RESPONSE:
<-- 200 https://jsonplaceholder.typicode.com/todos/1
...
<-- END HTTP
- For our
Request
, we want to print out the query parameters and the body of the request (if available) , headers and the URL; - For the
Response
, we want to print out the URL, headers, body and status code - As for the
Error
, we will want the status code and the error itself
class LoggingInterceptors extends Interceptor {
@override
FutureOr<dynamic> onRequest(RequestOptions options) {
print(
"--> ${options.method != null ? options.method.toUpperCase() : 'METHOD'} ${"" + (options.baseUrl ?? "") + (options.path ?? "")}");
print("Headers:");
options.headers.forEach((k, v) => print('$k: $v'));
if (options.queryParameters != null) {
print("queryParameters:");
options.queryParameters.forEach((k, v) => print('$k: $v'));
}
if (options.data != null) {
print("Body: ${options.data}");
}
print(
"--> END ${options.method != null ? options.method.toUpperCase() : 'METHOD'}");
return options;
}
@override
FutureOr<dynamic> onError(DioError dioError) {
print(
"<-- ${dioError.message} ${(dioError.response?.request != null ? (dioError.response.request.baseUrl + dioError.response.request.path) : 'URL')}");
print(
"${dioError.response != null ? dioError.response.data : 'Unknown Error'}");
print("<-- End error");
}
@override
FutureOr<dynamic> onResponse(Response response) {
print(
"<-- ${response.statusCode} ${(response.request != null ? (response.request.baseUrl + response.request.path) : 'URL')}");
print("Headers:");
response.headers?.forEach((k, v) => print('$k: $v'));
print("Response: ${response.data}");
print("<-- END HTTP");
}
}
Testing it out in with the Json Placeholder website, we get the following logged:
—> GET https:///jsonplaceholder.typicode.com/todos/1/
Headers:
requiresToken: true
queryParameters:
—> END GET
<— 200 https:///jsonplaceholder.typicode.com/todos/1/
Headers:
connection: [keep-alive]
set-cookie: [__cfduid=dd3fb888c5f062dd954e06e2e4c1166241567679659; expires=Fri, 04-Sep-20 10:34:19 GMT; path=/; domain=.typicode.com; HttpOnly]
cache-control: [public, max-age=14400]
transfer-encoding: [chunked]
date: [Thu, 05 Sep 2019 10:34:19 GMT]
content-encoding: [gzip]
vary: [Origin, Accept-Encoding]
age: [4045]
cf-cache-status: [HIT]
expect-ct: [max-age=604800, report-uri=“https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct”]
content-type: [application/json; charset=utf-8]
pragma: [no-cache]
server: [cloudflare]
x-powered-by: [Express]
access-control-allow-credentials: [true]
cf-ray: [51178cd29f9b724b-AMS]
etag: [W/“53-hfEnumeNh6YirfjyjaujcOPPT+s”]
via: [1.1 vegur]
x-content-type-options: [nosniff]
expires: [Thu, 05 Sep 2019 14:34:19 GMT]
Response: {userId: 1, id: 1, title: delectus aut autem, completed: false}
<— END HTTP
As a further note, we are currently using the print method for displaying the logs. This might not be ideal since these logs will also appear in the production app, which means that anyone who connects the phone with this app open and runs flutter logs
will be able to see the full output. As a better alternative, we can use debugPrint
with Product Flavors as seen in this article - debugPrint and the power of hiding and customizing your logs in Dart.
And that’s it 👻 with this we have at least mastered the basics of Interceptor
s in Dio and can now add more functionality, logging and better error handling to our apps.
As always, please do tell me on Twitter what are going to be your uses for them!
Want to get the latest articles and news? Subscribe to the newsletter here 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma