Call the API endpoints - the Chopper HTTP client

Photo by JJ Ying on Unsplash

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. Screenshot 2022-04-03 at 8.53.14.png

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.

  1. 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.
  2. In the convertRequest method we are checking the type of the request body. If it implements ToJsonConvertable and has a toJson() 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 the ToJsonConvertable 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