How to Generate API Clients in Flutter

A common task in most applications is to write clients to send and retrieve data from an API. Instead of writing those clients ourselves, we can generate API clients in Flutter. In this post, we will go over a complete implementation using a dummy API.

The JSONPlaceholder API

In this tutorial, we will be working with JSONPlaceholder API. This API offers dummy routes using all the possible HTTP methods. The data objects we will be working with are blog-related like posts and comments. In the table below you can see all the routes we will be using during this tutorial.

GET/posts
GET/posts/1
GET/posts/1/comments
GET/comments?postId=1
POST/posts
PUT/posts/1
DELETE/posts/1

Install the Packages to Generate API Clients in Flutter

To make it possible to generate API Clients we need to install the Chopper package along with the Chopper Generator and Build Runner package. This can be done by executing the following command inside your project:

flutter pub add chopper && flutter pub add --dev build_runner chopper_generator

After executing the command, check your pubspec.yaml file for the added dependencies. You should see the Chopper, Chopper Generator, and Build Runner packages included in the dependencies and dev_dependencies, like below:

dependencies:
  chopper: ^8.0.0

dev_dependencies:
  build_runner: ^2.4.9
  chopper_generator: ^8.0.0

Create the First Chopper Service (API Client)

After installing all the packages we can start by creating an abstract class that will extend the ChopperService class. A chopper service is used to define the base URL and all corresponding routes.

As mentioned before in this tutorial we will be working with blog-related data. So we will create an abstract PostService class. Inside this abstract class, we will place all the requests needed to show, add, update, and delete posts.

import 'package:chopper/chopper.dart';

part 'post_service.chopper.dart';

@ChopperApi(baseUrl: '/posts')
abstract class PostService extends ChopperService {
  static PostService create([ChopperClient? client]) =>
      _$PostService(client);

  @Get()
  Future<Response> posts();

  @Get(path: '/{id}')
  Future<Response> post(@Path() int id);

  @Post()
  Future<Response> add(@Body() Map<String, dynamic> json);

  @Put(path: '/{id}')
  Future<Response> update(@Path() int id, @Body() Map<String, dynamic> json);

  @Delete(path: '/{id}')
  Future<Response> delete(@Path() int id);
}

In the above code snippet, we start by creating the PostService class. This class extends the ChopperService class and has a static create function to create the service. At the top of the class, we use the ChopperApi() annotation to pass the baseUrl of this service which is /posts. Which in this case means that every route will start with /posts.

Inside the service, we define 5 functions:

  1. posts: to return all the posts.
  2. post: which takes an id parameter and returns a single post.
  3. add: which requires a json body and will simulate adding a post.
  4. update: which takes an id parameter, requires a json body and will simulate updating a post.
  5. delete: which takes an id parameter and will simulate deleting a post.

As you can see the HTTP methods are defined using annotations like @get(), @post(), @put(), and @delete() above the functions. The annotations can have a path parameter which can be used to add the path of the request. In this case, it is used to dynamically add the id to the routes. Inside the add and update functions we also have a @Body() parameter that will be used to pass the data for the post.

Generate post_service.chopper.dart

On line 3 of the code, we include the part directive pointing to the post_service.chopper.dart file. This file will be generated by the Chopper package. Within this file, Chopper will use all the defined functions of our service to create the API requests. To generate the file, simply execute the following command inside our project:

dart run build_runner build --delete-conflicting-outputs

The --delete-conflicting-ouputs flag is not necessarily needed but it ensures that generated files are overwritten.

Create the Chopper Client

After creating our PostService, we can continue by creating an instance of the ChopperClient. The ChopperClient has the required baseUrl attribute which we will set to the URL of our API: https://jsonplaceholder.typicode.com. It also takes a list of ChopperService instances.

import 'package:chopper/chopper.dart';
import 'package:chopper_client_generator/post_service.dart';

void main() async {
  final chopper = ChopperClient(
    baseUrl: Uri.parse('https://jsonplaceholder.typicode.com'),
    services: [
      PostService.create(),
    ],
  );

  final postService = chopper.getService<PostService>();

  try {
    final response = await postService.posts();

    if (response.isSuccessful) {
      final body = response.body;

      print(body);
    } else {
      throw Exception(response.error);
    }
  } catch (error) {
    print(error);
  }
}

In the above example, we created an instance of the ChopperClient and saved it on the chopper variable. Inside the client, we defined our base URL and added our PostService.

After that, we use the chopper variable to access our PostService using the getService function. On the service, we can call all our routes. You can see that we wrapped the API call with a try catch to catch possible exceptions.

Other than that the API call will return a response. On the response, we have access to the is isSuccessful getter, this getter will check if the statusCode of the response is higher or equal to 200 and smaller than 300.

If the response is successful we will print out the body otherwise we throw the error.

Calling the Get Posts and Post Requests

Now when we run our main function we get the following result from the API inside our terminal:

[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  },
]

As you can see the response we received is a list of JSON post objects.

If we want to make a different request we only have to change the function we call on the postService.

import 'package:chopper/chopper.dart';
import 'package:chopper_client_generator/post_service.dart';

void main() async {
  final chopper = ChopperClient(
    baseUrl: Uri.parse('https://jsonplaceholder.typicode.com'),
    services: [
      PostService.create(),
    ],
  );

  final postService = chopper.getService<PostService>();

  try {
    final response = await postService.post(2);

    if (response.isSuccessful) {
      final body = response.body;

      print(body);
    } else {
      throw Exception(response.error);
    }
  } catch (error) {
    print(error);
  }
}

In the above code, we changed the function we call on the postService to post. The post function takes an id parameter and will return the post with the matching id:

[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
]

Of course, you can call as many different requests on the postService as you want. However, ensure that you always wrap your requests in a try catch, in case something goes wrong in the backend.

Calling the Add, Update, and Delete Requests

To call the add and update requests we need to make some additional changes to ensure that the body of our response is correctly converted to JSON.

import 'package:chopper/chopper.dart';
import 'package:chopper_client_generator/post_service.dart';

void main() async {
  final chopper = ChopperClient(
    baseUrl: Uri.parse('https://jsonplaceholder.typicode.com'),
    converter: const JsonConverter(),
    errorConverter: const JsonConverter(),
    services: [
      PostService.create(),
    ],
  );

  final postService = chopper.getService<PostService>();

  try {
    final response = await postService.add({
      'title': 'foo',
      'body': 'bar',
      'userId': 1,
    });

    if (response.isSuccessful) {
      final body = response.body;

      print(body);
    } else {
      throw Exception(response.error);
    }
  } catch (error) {
    print(error);
  }
}

In this code snippet, we added the converter and errorConverter parameter to our ChopperClient instance. The Chopper package provides a JsonConverter class that can be used to convert JSON. The errorConverter is not necessary in this case. However, I just want to show you that you can also convert your errors to JSON.

As shown before the add function of the PostService class requires a body and we provide the JSON body that our JSONPlaceholder API expects. When we run our code you will see that we receive the following response:

{title: foo, body: bar, userId: 1, id: 101}

To update a post we can call the update function with the id of the post we want to modify and a JSON body with the changed values.

final response = await postService.update(1, {
  'title': 'foo',
  'body': 'bar',
  'userId': 1,
});

After running this code you will see the following response body inside your terminal:

{title: foo, body: bar, userId: 1, id: 1}

At last, we can also delete a post by calling the delete function with the id of the post we want to delete.

final response = await postService.delete(1);

If we call the above function we will receive an empty object back from our backend.

{}

Since we are using a dummy API, we are not actually adding, updating or deleting posts, however, we receive a similar response from the backend as you would for an actual API.

Path Resolution and Query Parameters

We have already used the path parameter of the request annotation to add the id to the URL. But you can also use the path parameter to modify the URL even further. Not only that we can also add query parameters to the URL.

import 'package:chopper/chopper.dart';

part 'post_service.chopper.dart';

@ChopperApi(baseUrl: '/posts')
abstract class PostService extends ChopperService {
  static PostService create([ChopperClient? client]) =>
      _$PostService(client);

  @Get()
  Future<Response> posts();

  @Get(path: '/{id}')
  Future<Response> post(@Path() int id);

  @Post()
  Future<Response> add(@Body() Map<String, dynamic> json);

  @Put(path: '/{id}')
  Future<Response> update(@Path() int id, @Body() Map<String, dynamic> json);

  @Delete(path: '/{id}')
  Future<Response> delete(@Path() int id);

  @Get(path: '/{id}/comments')
  Future<Response> comments(@Path() int id);

  @Get(path: 'https://jsonplaceholder.typicode.com/comments')
  Future<Response> commentsWithQuery({@Query() int postId = 1});
}

In the above example, we created the comments and the commentsWithQuery functions. We suffixed the URL for the comments route with /comments to ensure we are retrieving the comments from the backend. The comments functions also takes the id of a post.

For the commentsWithQuery function we override the complete path because the URL does not include the /posts base URL. We also added the @Query() parameter which will suffix the given URL in the path with ?postId=1 where 1 is the given id.

Remember after making changes in a ChopperService you need to regenerate its chopper.dart file. This can be done by executing the same command as before:

dart run build_runner build --delete-conflicting-outputs

Now we can call the comments function on our PostService.

final response = await postService.comments(1);

This will give us the following JSON response:

[
  {
    "postId": 1,
    "id": 1,
    "name": "id labore ex et quam laborum",
    "email": "Eliseo@gardner.biz",
    "body": "laudantium enim quasi est quidem magnam voluptate ipsam eos\ntempora quo necessitatibus\ndolor quam autem quasi\nreiciendis et nam sapiente accusantium"
  }
]

We can also call the commentsWithQuery function.

final response = await postService.commentsWithQuery(postId: 5);

This will result in the following:

[
  {
    "postId": 5,
    "id": 21,
    "name": "aliquid rerum mollitia qui a consectetur eum sed",
    "email": "Noemie@marques.me",
    "body": "deleniti aut sed molestias explicabo\ncommodi odio ratione nesciunt\nvoluptate doloremque est\nnam autem error delectus"
  }
]

Define Response Types in Post Service

So far we have been working with plain JSON responses. Even though this approach works, it is not very convenient inside our codebase. A better approach would be to serialize the JSON into Dart objects. Luckily the package is flexible, let us go over the implementation.

Create BlogPost Model

First of all, we want to create a BlogPost model to convert the posts we receive from our backend.

class BlogPost {
  int userId;
  int id;
  String title;
  String body;

  BlogPost({
    required this.userId,
    required this.id,
    required this.title,
    required this.body,
  });

  factory BlogPost.fromJson(Map<String, dynamic> json) => BlogPost(
        userId: json["userId"],
        id: json["id"],
        title: json["title"],
        body: json["body"],
      );

  Map<String, dynamic> toJson() => {
        "userId": userId,
        "id": id,
        "title": title,
        "body": body,
      };
}

We created a BlogPost class with the same attributes that we receive from the API. We have added a factory called fromJson so that we can create an instance of BlogPost from JSON. We also created the toJson function so that we can convert a BlogPost instance into JSON.

If you need help creating your models for your own project you can check out the following article: How to Deserialize JSON Responses in Flutter or check out the quicktype website.

If you are following allong ensure to name the above class BlogPost and not Post. I agree that Post is a better name. However, it will clash with the package’s Post class and the implementation discussed in this section will not work.

Now instead of returning a regular Response, we can now return a Response with a type.

import 'package:chopper/chopper.dart';
import 'package:chopper_client_generator/blog_post.dart';

part 'post_service.chopper.dart';

@ChopperApi(baseUrl: '/posts')
abstract class PostService extends ChopperService {
  static PostService create([ChopperClient? client]) =>
      _$PostService(client);

  @Get()
  Future<Response<List<BlogPost>>> posts();

  @Get(path: '/{id}')
  Future<Response<BlogPost?>> post(@Path() int id);

  @Post()
  Future<Response<BlogPost>> add(@Body() Map<String, dynamic> json);

  @Put(path: '/{id}')
  Future<Response<BlogPost>> update(@Path() int id, @Body() Map<String, dynamic> json);

  @Delete(path: '/{id}')
  Future<Response> delete(@Path() int id);

  @Get(path: '/{id}/comments')
  Future<Response> comments(@Path() int id);

  @Get(path: 'https://jsonplaceholder.typicode.com/comments')
  Future<Response> commentsWithQuery({@Query() int postId = 1});
}

In the above code snippet, instead of returning just a Response we are returning either a Reponse with a list of BlogPost objects or a Response with a single BlogPost object. This way the JSON received from the API will immediately be serialized into a Dart object.

Of course, when we make changes to a ChopperService class, we have to regenerate the chopper file:

dart run build_runner build --delete-conflicting-outputs

Create JsonSerializationConverter

Just changing the Response types of a ChopperService is not enough. Because like before we have to ensure that it is converted correctly. Unfortunately using the package’s JsonConverter will not do the trick. However, we can create the following converter that will be able to serialize and deserialize the data from the API using Dart objects.

import 'dart:async' show FutureOr;

import 'package:chopper/chopper.dart';

typedef JsonFactory<T> = T Function(Map<String, dynamic> json);

class JsonSerializationConverter extends JsonConverter {
  const JsonSerializationConverter(this.factories);

  final Map<Type, JsonFactory> factories;

  T? _decodeMap<T>(Map<String, dynamic> values) {
    final jsonFactory = factories[T];

    if (jsonFactory == null || jsonFactory is! JsonFactory<T>) {
      return null;
    }

    return jsonFactory(values);
  }

  List<T> _decodeList<T>(Iterable 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 as List);

    if (entity is Map) return _decodeMap<T>(entity as Map<String, dynamic>);

    return entity;
  }

  @override
  FutureOr<Response<ResultType>> convertResponse<ResultType, Item>(
    Response response,
  ) async {
    final jsonResponse = await super.convertResponse(response);

    return jsonResponse.copyWith<ResultType>(
        body: _decode<Item>(jsonResponse.body));
  }
}

In the JsonSerializationConverter we override the convertResponse function. Inside our override, we still call the convertResponse function of the parent class. However, we change the return response to return either a list of Dart objects or a single Dart object.

This is done inside the _decode function. This function determines based on the given jsonResponse.body whether we want to return a list of objects or a single object. Once the function has determined what to return. It will check inside the factories attribute whether it can find the correct type.

In this case, it is looking for the BlogPost type. Once it finds the correct type it will convert the JSON response to the correct list of BlogPost objects or single BlogPost object.

Implement the JsonSerializationConverter

After creating the JsonSerializationConverter we have to ensure that our ChopperClient uses it.

import 'package:chopper/chopper.dart' show ChopperClient, JsonConverter;
import 'package:chopper_client_generator/blog_post.dart';
import 'package:chopper_client_generator/json_serialization_converter.dart';
import 'package:chopper_client_generator/post_service.dart';

void main() async {
  final chopper = ChopperClient(
    baseUrl: Uri.parse('https://jsonplaceholder.typicode.com'),
    converter: const JsonSerializationConverter({BlogPost: BlogPost.fromJson}),
    errorConverter: const JsonConverter(),
    services: [
      PostService.create(),
    ],
  );

  final postService = chopper.getService<PostService>();

  try {
    final response = await postService.post(5);

    if (response.isSuccessful) {
      final body = response.body;

      print(body);
    } else {
      throw Exception(response.error);
    }
  } catch (error) {
    print(error);
  }
}

In the above code, instead of using the regular JsonConverter we are now passing an instance of our custom JsonSerializationConverter. Our custom converter takes a map of Type and factory and we used it to pass our BlogPost type and its factory.

Afterward, we can run our main function as before and you will see that we get the following output:

Instance of 'BlogPost'

Conclusion

In this post, you learned how to generate API clients in Flutter using the Chopper package. The initial setup might require some time and knowledge but after it is done you can quickly set up a communication with your backend.

You also learned how to deserialize JSON data into Dart objects, making the data much easier to work with. While we covered many aspects of the Chopper package in this post, there is still much more to learn. If you’re interested, feel free to check out their official documentation.

Tijn van den Eijnde
Tijn van den Eijnde
Articles: 40

Leave a Reply

Your email address will not be published. Required fields are marked *