Table of contents
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:
Models: These are the classes that represent the data that we are going to send or receive from the API.
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 aDataSource
orDataProvider
. They are not so necessary but many like to use them.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/