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.
Table of contents
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:
posts
: to return all the posts.post
: which takes anid
parameter and returns a single post.add
: which requires ajson
body and will simulate adding a post.update
: which takes anid
parameter, requires ajson
body and will simulate updating a post.delete
: which takes anid
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 notPost
. I agree thatPost
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.