Better HTTP with Dio

Better HTTP with Dio

"It was me, Dio!"

The scenario in our projects where we must call an external API via HTTP is never missing.

To achieve this in Dart, we have the official package:http. This provides us with functions and classes that fulfill what is necessary to make these HTTP calls in a simple way and without much torture.

http is characterized by being based on the Future data type for requests and can also be used on all stable platforms that Flutter supports.

With http we can make calls in a few lines as follows...

import 'package:http/http.dart' as http;

Future<void> main() async {
  final url = 'https://jsonplaceholder.typicode.com/todos/1';

  final response = await http.get(url);

  print('Response statusCode: ${response.statusCode}');
  print('Response body: ${response.body}');
}

And many examples in the community are made using http because in many cases it solves more than enough.

But not all is so easy. Sometimes we have use cases where our APIs expose us to endpoints that contain more complex functionality like uploading/downloading a file, streaming data, etc.

There are many cases where this functionality can be implemented in http but the detail is that it is not very easy to do it, here you can see an example.

Enter Dio

As always, I like to show you alternatives to the usual packages we use and show you the advantages, disadvantages and why these options are worth trying in your future developments.

package:dio is an alternative to package:http and helps us incorporate everything I was telling you about so we don't have to spend time building on top of http to develop functionality.

Its usage is very similar to http and we just need to instantiate a Dio object which would already be our HTTP client.

import 'package:dio/dio.dart';

Future<void> main() async {
  final url = 'https://jsonplaceholder.typicode.com/todos/1';

  final response = await Dio().get(url);

  print('Response statusCode: ${response.statusCode}');
  print('Response body: ${response.data}');
}

You can see that the only change I made was to change the body property of the Response in http to the data property of the Response in package:dio. The only difference between body and data is that the latter is decoded by Dio if it is a Map.

But hey, that's pretty basic, right?

Let's go with the advantages offered by this package.

BaseOptions

dio allows us to create an object with all the base configurations to interact with our API. We can inject this object as dependencies of our classes in charge of the calls to the API (or APIs).

By the way, we can define different Dio objects, as I mentioned, each one with a different configuration for specific endpoints or APIs. But let's see how this configuration is done.

import 'package:dio/dio.dart';

Future<void> main() async {
  final options = BaseOptions(
    baseUrl: 'https://jsonplaceholder.typicode.com/',
    connectTimeout: 5000,
    receiveTimeout: 3000,
    responseType: ResponseType.json,
  );

  final dio = Dio(options);

  final response = await dio.get('todos/1');

  print('Response statusCode: ${response.statusCode}');
  print('Response body: ${response.data}');
}

If you run this code, you'll notice that we can still use that Dio object to make the same request with a set of our baseUrl. So when using the get method we only need to specify the endpoint we want to access to retrieve that information.

As I said, we can create several objects that contain different BaseOptions in case they implement a microservices architecture where they can have a client for each API they use.

import 'package:dio/dio.dart';

Future<void> main() async {
  // ... previous request

  final optionsRickAndMorty = BaseOptions(
    baseUrl: 'https://rickandmortyapi.com/api',
    connectTimeout: 5000,
    receiveTimeout: 3000,
    responseType: ResponseType.json,
  );

  final dioRickAndMorty = Dio(optionsRickAndMorty);
  final ricksponse = await dioRickAndMorty.get('/episode/1');

  print('Ricksponse statusCode: ${ricksponse.statusCode}');
  print('Ricksponse episode name: ${ricksponse.data['name']}');
}

Here I make a call to another very famous API that my friends from Flutter Spain use, RickAndMortyAPI. In this case, I create another client and another configuration to call that other endpoint and I still show the information of both.

Microservices enthusiasts will highly appreciate this functionality. You can check more of the options by here.

By the way, you can see that data works identically to body when we want to access a specific property of our Response.

Concurrency

This is not a feature of dio but I would like to mention it since many do not know that it exists and it is very useful.

As you saw in the previous example, I am making 2 HTTP requests sequentially and at first glance it is fine, perhaps it would be necessary to separate these functions each one on its own so as not to have such a large main function. But another way we can organize our code is with a Future.wait.

final concurrentResponse = await Future.wait([
    dioPlaceholder.get('todos/1'),
    dioRickAndMorty.get('/episode/1'),
]);

concurrentResponse.forEach((res) => print('\n${res.data}'));

With Future.wait we can send the list of Futures or requests that we are going to make and this method returns a list of responses where we can access each one and in this case simply print it to the console.

Download files

If you have projects that require the internal use of files, they will likely require downloading some files to save on the devices that your app operates.

This can be done with http but it takes us a long time to implement from scratch. Yes, you guessed it, dio makes it easy for us like this:

import 'package:dio/dio.dart';

Future<void> main() async {
  final client = Dio();
  final url = 'https://images.unsplash.com/photo-1567026392301-672e510f3369';

  final directory = await getTemporaryDirectory();
  final fileName = '${directory.path}/downloads';

  await client.download(
    url,
    fileName,
    options: Options(headers: {HttpHeaders.acceptEncodingHeader: '*'}),
    onReceiveProgress: (received, total) {
      if (total != -1) {
        final pos = received / total * 100;
        print(pos);
      }
    },
  );
}

Analyzing how to do these downloads, we must have --as usual-- our Dio client, the url of the file we want to download, a temporary directory that we get using package:path_provider, and also a directory on the device where you save those files you download.

Then with the asynchronous download method, we have all the communication with this file hosted on a remote server. We can optionally assign additional configuration via Options, for example, an acceptEncoding that allows us to tell the server which compression algorithms for the content our client supports.

The most important thing here is that download gives us access to a callback called onReceiveProgress which gives us two integer values: received and total. The first gives us the number of bytes of the file that have been received on the device and the second is the total number of bytes of the file.

Now they can safely download files in their apps.

⚠️ If there is an error somewhere in the download, dio stops and deletes everything that was downloaded.

Upload files

Uploading files is a normal POST request with a few tweaks involved.

import 'package:dio/dio.dart';

Future<void> main() async {
  final client = Dio();
  final url = 'https://some-api.dev/';

  final directory = await getTemporaryDirectory();
  final fileName = '${directory.path}/rant_about_the_world.txt';

  final payload = FormData.fromMap({
    'user': 'marcossevilla',
    'essay': await MultipartFile.fromFile(fileName),
  });

  await client.post(
    '$url/user/new',
    data: payload,
    onSendProgress: (sent, total) {
      final progress = sent / total * 100;
      print(progress);
    },
  );
}

What we do in this part is use a file from the device, whose location we contain --additionally its name-- in fileName. And next, we form a FormData object, similar to the one used in HTML, which is capable of saving the information with a fromMap constructor, and if we want to send a file, which will usually take a moment to upload, we use the class from MultipartFile which contains an asynchronous fromFile method to upload the file.

When uploading the file, we must wait for MultipartFile to finish executing its method, so we use an await.

Likewise, the POST provides us with the two callbacks onReceiveProgress and onSendProgress that allow us to calculate percentages to display download/upload information in our graphical interface.

Bonus tips

I can't leave out my favorite topics: architecture and code structure. I'm not going to make a long article explaining how to use dio for your architectures, but how to structure a data layer based on dio.

Orienting it to a microservices architecture where we have different APIs for separate modules (or even very specific actions), I recommend separating them by API that you use. This way you can have a client for each API with whatever BaseOptions you need.

data/
    |- rick_and_morty_api/
    |- joke_api/
    |- another_api/

Now that we have our API folder, we can separate it into 2–3 main groups:

  1. Models: These are the classes that represent the data that we are going to send or receive from the API.

  2. Parsers: These are classes that abstract the conversion logic of the String encoded in JSON to a Dart object. This same logic could be implemented either inside the model or in a DataSource or DataProvider. They are not so necessary but many like to use them.

  3. Datasources / DataProviders: They are in charge of executing the calls to the APIs through a specific client.

Our folder structure would look like this:

data/
    |- rick_and_morty_api/
        |- models/
        |- parsers/
        |- data_providers/