Call the API endpoints - the Chopper HTTP client
Implement the communication layer of our Tinder for Cats and Dogs application
Intro
This is the second part of the blog post series that aims to implement the February Challenge of flutterchallenge.dev the Tinder for Cats and Dogs.
This post shows an implementation of the communication layer of our application by using the chopper package.
You can find the source code at github.com/dtengeri/tinder_cat_dog_app
What is chopper?
What does the chopper package provide us and why it is useful?
chopper is an HTTP client generator. It generates source code for you, to manage HTTP calls in your app. It generates the boring stuff, you just need to define the methods and annotate them with some meta information, chopper will translate it to HTTP calls and handles JSON serialization, HTTP headers and every HTTP related configuration for you.
Add chopper to your app
Let's add chopper to the Tinder for Cats and Dogs application.
Just for a remainder, the Tinder for Cats and Dogs was the first challenge of flutterchallenge.dev. It provides a REST API to get the list of available cats and dogs and to store the user's votes.
Let's start with adding the required dependencies to our application.
dependencies:
chopper: ^4.0.5
dev_dependencies:
build_runner: ^2.1.11
chopper_generator: ^4.0.5
Since chopper is a code generator, we are going to use the build_runner to generate the code the same way as for freezed in the first part of the series.
Chopper basics
chopper main class is the ChopperClient
which does the communication with our backend service. It relies on services. A service is a ChopperService
, which is the generated code for the endpoints. Since we are using freezed it provides us JSON serialization as well. We can create a custom converter for chopper so it can automatically convert the request and response body from JSON to our models.
Create the first service
Let's start with creating our first service, for the /cats
endpoint.
import 'package:chopper/chopper.dart';
import 'package:tinder_cat_dog_app/features/animals/animals.dart';
// The place of the generated code.
part 'cat_list_service.chopper.dart';
// This annotation tells chopper that this service
// should send its requests to the /cats path.
@ChopperApi(baseUrl: '/cats')
abstract class CatListService extends ChopperService {
// This method creates the service based on the generated code.
static CatListService create([ChopperClient? client]) =>
_$CatListService(client);
// This tells chopper to generate a method that wil send a GET request
// to the /cats endpoint and returns a list of Cat models.
@Get()
Future<Response<List<Cat>>> getCats();
}
chopper provides annotations that can be used to aid the code generation. In the CatListService
we define a getCats()
method that will send a GET
request to our REST API's /cats
endpoint. The endpoint's response is in JSON format
[
{
"id": "9eef0518d8b5a333ee1fcb1a06a1474a23192c6a",
"name": "Luna",
"path": "/cats/cat001.jpeg"
}
]
The generated code processes this JSON string and converts its content to our Cat
model and adds all cats to a list. And all of this happens by just adding the @Get()
annotation.
Run
flutter pub run build_runner build --delete-conflicting-outputs
to generate the service or watch your file changes and generate the chopper services automatically by
flutter pub run build_runner watch --delete-conflicting-outputs
Processing JSON strings
We can provide converters to ChopperClient
that could process JSON strings and convert them to our own models.
import 'package:chopper/chopper.dart';
import 'package:tinder_cat_dog_app/features/core/core.dart';
/// This function gets a JSON map and returns a model of type [T].
typedef JsonFactory<T> = T Function(Map<String, dynamic> json);
/// A [JsonConverter] for chopper that uses converts our models to and
/// from JSON.
class JsonSerializableConverter extends JsonConverter {
/// Creates a new [JsonSerializableConverter] with the given [factories].
const JsonSerializableConverter(this.factories);
/// Collection of the factories of our model [Type]s.
final Map<Type, JsonFactory> factories;
T _decodeMap<T>(Map<String, dynamic> values) {
/// Get jsonFactory using Type parameters
/// if not found or invalid, throw error or return null
final jsonFactory = factories[T];
if (jsonFactory == null || jsonFactory is! JsonFactory<T>) {
/// throw serializer not found error;
throw ArgumentError('Serializer not found');
}
return jsonFactory(values);
}
List<T> _decodeList<T>(List values) =>
values.where((v) => v != null).map<T>((v) => _decode<T>(v)).toList();
dynamic _decode<T>(entity) {
if (entity is Iterable) return _decodeList<T>(entity.toList());
if (entity is Map<String, dynamic>) return _decodeMap<T>(entity);
return entity;
}
/// Converts a reponse coming from the server to [ResultType].
@override
Response<ResultType> convertResponse<ResultType, Item>(Response response) {
final jsonRes = super.convertResponse(response);
return jsonRes.copyWith<ResultType>(body: _decode<Item>(jsonRes.body));
}
/// Converts the request body if it implements [ToJsonConvertable].
///
/// It calls [ToJsonConvertable.toJson] method on the body to convert
/// the body to JSON string.
@override
Request convertRequest(Request request) {
final convertedRequest = super.convertRequest(request);
if (request.body is ToJsonConvertable) {
return convertedRequest.copyWith(
body: (request.body as ToJsonConvertable).toJson(),
);
}
return convertedRequest;
}
@override
Response convertError<ResultType, Item>(Response response) {
final jsonRes = super.convertError(response);
return jsonRes;
}
}
There are 2 key parts in the converter.
- We have to provide a map for all the types that our chopper client can handle through the REST APIs. This map contains the factory functions that could convert a JSON map to the given type. Such a function is
Cat.fromJson
, which is generated by freezed. - In the
convertRequest
method we are checking the type of the request body. If it implementsToJsonConvertable
and has atoJson()
method, it will use it to convert the data class to JSON. This is true for our models generated by freezed. We only have to extend or implement theToJsonConvertable
class.
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:tinder_cat_dog_app/features/core/core.dart';
part 'vote_param.freezed.dart';
part 'vote_param.g.dart';
/// Extend [ToJsonConvertable] so [VoteParam] can be used
/// as a request body and it will be converted automatically to JSON.
@freezed
class VoteParam extends ToJsonConvertable with _$VoteParam {
const factory VoteParam({
@JsonKey(name: 'vote_type') required String voteType,
@JsonKey(name: 'animal_id') required String animalId,
required bool liked,
}) = _VoteParam;
factory VoteParam.fromJson(Map<String, dynamic> json) =>
_$VoteParamFromJson(json);
}
The ToJsonConvertable
is an abstract class with the definition of toJson()
.
abstract class ToJsonConvertable {
Map<String, dynamic> toJson();
}
Configure the requests
Let's see a more complex service that uses variables in the URLs, sends data as the request body and uses Authorization headers.
import 'package:chopper/chopper.dart';
import 'package:tinder_cat_dog_app/features/animals/animals.dart';
part 'vote_service.chopper.dart';
@ChopperApi(baseUrl: '/votes')
abstract class VoteService extends ChopperService {
static VoteService create([ChopperClient? client]) => _$VoteService(client);
// We can provide values for HTTP headers like authorization token
// with the @Header annotation.
@Get()
Future<Response<List<Vote>>> getVotes({
@Header('Authorization') required String token,
});
// We can use variables in the URL by using {variable-name} in the
// @Get() annotation and marking a method parameter with @Path().
@Get(path: '/{id}')
Future<Response<Vote>> getVote({
@Path() required String id,
@Header('Authorization') required String token,
});
// We can provide the body of a POST request by annotate a method parameter
// with @Body().
@Post()
Future<Response<Vote>> createVote({
@Body() required VoteParam voteParam,
@Header('Authorization') required String token,
});
@Put(path: '/{id}')
Future<Response<Vote>> updateVote({
@Path() required String id,
@Body() required VoteParam voteParam,
@Header('Authorization') required String token,
});
@Delete(path: '/{id}')
Future<Response> deleteVote({
@Path() required String id,
@Header('Authorization') required String token,
});
}
We can use variables in the URL, which will be replaced by one of the method parameters.
@Get(path: '/{id}')
Future<Response<Vote>> getVote({
@Path() required String id,
@Header('Authorization') required String token,
});
The {id}
in the path creates a placeholder in the URL and the @Path()
annotation before the id
method parameter connects it to this placeholder.
We can provide HTTP headers as well by using @Header()
. We can use it for example to set a JWT token in the Authorization
header.
Create the ChopperClient
Let's configure the ChopperClient
with our generated services and converter, so we can use it in our app. We're going to use a factory called ChopperClientFactory
.
import 'package:chopper/chopper.dart';
import 'package:http/http.dart';
import 'package:tinder_cat_dog_app/features/animals/animals.dart';
import 'package:tinder_cat_dog_app/features/core/core.dart';
class ChopperClientFactory {
/// Creates a new [ChopperClient] with our services and converter.
static ChopperClient create([Client? client]) => ChopperClient(
client: client,
baseUrl: 'https://tinder-cat-dog-api.herokuapp.com',
services: [
// These are the services that we made and chopper
// generated their logic.
AuthService.create(),
CatListService.create(),
DogListService.create(),
VoteService.create(),
],
// This is our custom converter.
converter: const JsonSerializableConverter({
Cat: Cat.fromJson,
Dog: Dog.fromJson,
Vote: Vote.fromJson,
}),
);
}
Use our client
It's time to use our client to make HTTP requests. Let's create a new instance of it
final chopper = ChopperClientFactory.create();
Get the list of cats
final catListService = chopper.getService<CatListService>();
final response = await catListService.getCats();
if (response.statusCode == 200 && response.body != null) {
for (Cat cat in response.body!) {
// Do something with the cat.
}
}
Create a vote
final voteService = chopper.getService<VoteService>();
final response = await voteService.createVote(
token: 'Bearer xxxx',
voteParam: const VoteParam(voteType: 'cat', animalId: '1', liked: true),
);
Summary
In this post, we created the communication layer of our application that uses the REST API and our data classes. The chopper package provides code generation features that help us quickly create services that communicate with our API. It generates the code for making HTTP requests. Our task is to annotate our methods and parameters and the rest is done by chopper.
Resources
You can find the source code of the application at github.com/dtengeri/tinder_cat_dog_app