Chopper (Retrofit for Flutter) #2 – Interceptors

Having conquered the basics of Chopper, such as making HTTP requests, in the previous part, it's time to take a detailed look at interceptors. They are a bit more high-level components of Chopper and they are used to perform some actions right before sending out a request, or right after receiving a response.

Interceptor foundations

The nature of interceptors is that they run with every request or response performed on a ChopperClient. If you want to perform a client-wide operation, interceptors are just the right thing to employ.

The word "client-wide" is important. In the previous part, you learned that Chopper has the concept of a ChopperService - it contains methods for making requests. One service is usually reserved for one endpoint (e.g. "/posts", "/comments") and usually there are multiple services per one ChopperClient (e.g. PostService + CommentService).

Do you want to keep a statistic of how many times a certain URL has been called? Do you want to add headers to every request? Do you want to tell the user to switch to WiFi when he's about to download a large file? All of this is a job for an interceptor.

Adding interceptors

As you've learned above, interceptors are applied to the whole ChopperClient. They will therefore be specified inside the client's constructor. Even though there are two types of interceptors - request & response, they are both added into one list parameter.

post_api_service.dart

...
static PostApiService create() {
  final client = ChopperClient(
    ...
    interceptors: [
      // Both request & response interceptors go here
    ],
  );

  return _$PostApiService(client);
}

Built-in interceptors

Being an awesome library, Chopper comes bundled with a couple of useful interceptors.

HeadersInterceptor

One of these interceptors is a HeadersInterceptor which will add headers to all of the requests performed by the ChopperClient. Headers are a Map<String, String>. Add the folowing to the interceptors list on a client:

HeadersInterceptor({'Cache-Control': 'no-cache'})

The other built-in interceptors are made for debugging purposes. 

HttpLoggingInterceptor

HttpLoggingInterceptor is a very useful tool for finding out detailed information about requests and responses. Once you set it up, you'll see detailed logs such as this one:

Log output from a SUCCESS 200 response

Chopper uses the standard logging package which comes directly from the Dart team. Before you can see logs in the console, you need to configure this package - the best place is the main() method. 

main.dart

import 'package:logging/logging.dart';

void main() {
  _setupLogging();
  runApp(MyApp());
}

// Logger is a package from the Dart team. While you can just simply use print()
// to easily print to the debug console, using a fully-blown logger allows you to
// easily set up multiple logging "levels" - e.g. INFO, WARNING, ERROR.

// Chopper already uses the Logger package. Printing the logs to the console requires
// the following setup.
void _setupLogging() {
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen((rec) {
    print('${rec.level.name}: ${rec.time}: ${rec.message}');
  });
}

All that's left is to add the following to the interceptor list on the ChopperClient:

HttpLoggingInterceptor()

CurlInterceptor

This is the last built-in one. If you're not very good with CURL and you'd like to see the CURL command for the request made by the app, add the following:

CurlInterceptor()

Then, after making GET or POST requests through Chopper, you'll get it printed out in the console. Again, follow the above steps to enable logging with the logging package.

CURL command representing a GET request with a custom header

Custom interceptors

Creating your own interceptors to run custom logic before requests or after responses can be done in 2 ways:

  1. Simple anonymous functions
  2. Classes implementing RequestInterceptor or ResponseInterceptor

Anonymous functions

These are perfect for quick little interceptors which don't contain a lot of logic. Usually, it's better to use the second option - create a separate class. Otherwise you might end up with a messy codebase. As always, single responsibility principle (SRP) is king!

If you'd rather trade in SRP for quickness, this is how you define anonymous interceptors. Again, all of this goes into the interceptors list of the ChopperClient.

post_api_service.dart

static PostApiService create() {
  final client = ChopperClient(
    ...
    interceptors: [
      ...
      (Request request) async {
        if (request.method == HttpMethod.Post) {
          chopperLogger.info('Performed a POST request');
        }
        return request;
      },
      (Response response) async {
        if (response.statusCode == 404) {
          chopperLogger.severe('404 NOT FOUND');
        }
        return response;
      },
    ],
  );

  return _$PostApiService(client);
}

Request and response interceptors differ only in the type of their parameter.

Interceptors have to always return a request/response. Otherwise, the next called interceptor or other Chopper code will receive a null request/response. And you know what happens with nulls... 😬

Separate classes

What if you want to prevent the user from downloading large files unless he's on WiFi? That sounds like it will be the best to make a separate RequestInterceptor class for that. ResponseInterceptors are, of course, done in the same manner.

Before implementing our MobileDataInterceptor, we need to add one package to the project.

pubspec.yaml

dependencies:
  ...
  connectivity: ^0.4.3+2

Inside the interceptor we want to throw a custom MobileDataCostException if the user is on mobile data and is trying to access large files. 

mobile_data_interceptor.dart

import 'dart:async';

import 'package:chopper/chopper.dart';
import 'package:connectivity/connectivity.dart';

class MobileDataInterceptor implements RequestInterceptor {
  @override
  FutureOr<Request> onRequest(Request request) async {
    final connectivityResult = await Connectivity().checkConnectivity();

    final isMobile = connectivityResult == ConnectivityResult.mobile;
    // Checking for large files is done by evaluating the URL of the request
    // with a regular expression. Specify all endpoints which contain large files.
    final isLargeFile = request.url.contains(RegExp(r'(/large|/video|/posts)'));

    if (isMobile && isLargeFile) {
      throw MobileDataCostException();
    }

    return request;
  }
}

class MobileDataCostException implements Exception {
  final message =
      'Downloading large files on a mobile data connection may incur costs';
  @override
  String toString() => message;
}

After adding a MobileDataInterceptor instance to the list of interceptors, the exception will be thrown. Because of the way we've set up the app in the previous part, this exception will not crash the app - that's because we're using a FutureBuilder widget which kind of automatically handles exceptions.

Still, we should show some message to the user to tell him what's going on. In this example app, we will display a Text widget to keep things simple.

home_page.dart

...
FutureBuilder<Response> _buildBody(BuildContext context) {
  return FutureBuilder<Response>(
    future: Provider.of<PostApiService>(context).getPosts(),
    builder: (context, snapshot) {
      if (snapshot.connectionState == ConnectionState.done) {
        // Exceptions thrown by the Future are stored inside the "error" field of the AsyncSnapshot
        if (snapshot.hasError) {
          return Center(
            child: Text(
              snapshot.error.toString(),
              textAlign: TextAlign.center,
              textScaleFactor: 1.3,
            ),
          );
        }
        // Snapshot's data is the Response
        // You can see there's no type safety here (only List<dynamic>)
        final List posts = json.decode(snapshot.data.bodyString);
        return _buildPosts(context, posts);
      } else {
        // Show a loading indicator while waiting for the posts
        return Center(
          child: CircularProgressIndicator(),
        );
      }
    },
  );
}
...

By the way, if the user is not connected to the Internet at all, HTTP package (which the Chopper uses) will throw its own exception and its message will be displayed in the Text widget as well.

Conclusion

Interceptors provide a way to run code before sending out requests and after receiving responses. They allow you to add some pretty powerful logic, like the MobileDataInterceptor above, or to add only a simple logging functionality.

In the next part, you will learn how to convert the dynamic data which is currently in the Response to a custom class using the amazing BuiltValue package.

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

>