You now know almost all the things Chopper has to offer which you'll need on a daily basis. Sending requests, obtaining responses, adding interceptors... There's only one thing missing which will plug a hole in a clean coder's heart - type safety.
JSON itself is not typesafe, we'll have to live with it. However, we can make our code a lot more readable and less error prone if we ditch dynamic data for a real data type. Chopper offers an amazing way to convert request and response data with the help of a library of your own choice. Our choice for for this tutorial will be built_value.
Built Value is arguably the best choice when you want to create immutable data classes (with all the bells and whistles like copying and value equality) AND on top of that, it has a first-class JSON serialization support. You can learn more about this library from the tutorial below.
Making a data class
The first step in making code type-safe is to, well... add some types. Since we will be using built_value, let's first add it as a dependency.
pubspec.yaml
dependencies:
...
built_value: ^6.7.0
dev_dependencies:
...
built_value_generator: ^6.7.0
Throughout this series we've been creating an app for viewing posts from the JSON Placeholder API. Just in case you need a little refresher on how the JSON response looks like, here it is:
GET /posts
[
{
"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"
},
// 99 additional objects in the list...
]
Creating a BuiltPost class
We're interested in all the fields except userId. Let's now create a BuiltPost class which will hold all of this data. Similar to Chopper itself, Built Value also utilizes source generation, so we'll create a new file built_post.dart and the actual implementation will be inside a generated built_post.g.dart file. To keep things organized, we'll even put all this into a new model folder.
built_post.dart
import 'package:built_value/built_value.dart';
import 'package:built_value/serializer.dart';
part 'built_post.g.dart';
abstract class BuiltPost implements Built<BuiltPost, BuiltPostBuilder> {
// IDs are set in the back-end.
// In a POST request, BuiltPost's ID will be null.
// Only BuiltPosts obtained through a GET request will have an ID.
@nullable
int get id;
String get title;
String get body;
BuiltPost._();
factory BuiltPost([updates(BuiltPostBuilder b)]) = _$BuiltPost;
static Serializer<BuiltPost> get serializer => _$builtPostSerializer;
}
This is a pretty standard Built Value data class, but in case you didn't watch the separate built_value tutorial (you should!), here's a quick run down:
- Fields are get-only properties - data will actually be stored in the generated class.
- The default constructor is private, there's a factory taking in a Builder instead.
- Fields' values are set through the Builder.
- Specifying a serializer property generates a class _$BuiltPostSerializer, which is what we'll use to convert that ugly dynamic data into our beautiful BuiltPost data class.
Adding BuiltPost to global serializers
Yes, once we generate code, we'll have a serializer for BuiltPost classes. It turns out though that this is not enough! There's an entire ecosystem of other serializers for types like integer, String, bool and other primitives.
To successfully serialize and deserialize BuiltPost, our app will have to use it in conjunction with other serializers. We can accomplish this by adding BuiltPost's serializer to the list of all serializers built_value has to offer.
serializers.dart
import 'package:built_value/serializer.dart';
import 'package:built_value/standard_json_plugin.dart';
import 'built_post.dart';
part 'serializers.g.dart';
@SerializersFor(const [BuiltPost])
final Serializers serializers =
(_$serializers.toBuilder()..addPlugin(StandardJsonPlugin())).build();
Generating code
As usual, to initiate source generation, run the following in the terminal. We'll use the watch command to continue building code down the line when we change PostApiService implementation.
Updating the PostApiService
We want the methods inside PostApiService to return Responses which hold either a list (BuiltList in this case) of BuiltPosts, or just a single BuiltPost. To make conversion to BuiltValue classes possible, we are going to create a BuiltValueConverter later on. Even though it doesn't yet exist, replace the default JsonConverter with it just so that we won't have to come back to this file.
post_api_service.dart
import 'package:chopper/chopper.dart';
import 'package:built_collection/built_collection.dart';
import 'package:retrofit_prep/model/built_post.dart';
import 'built_value_converter.dart';
part 'post_api_service.chopper.dart';
@ChopperApi(baseUrl: '/posts')
abstract class PostApiService extends ChopperService {
@Get()
// Update the type parameter of Response to BuiltList<BuiltPost>
Future<Response<BuiltList<BuiltPost>>> getPosts();
@Get(path: '/{id}')
// For single returned objects, response will hold only one BuiltPost
Future<Response<BuiltPost>> getPost(@Path('id') int id);
@Post()
Future<Response<BuiltPost>> postPost(
@Body() BuiltPost post,
);
static PostApiService create() {
final client = ChopperClient(
baseUrl: 'https://jsonplaceholder.typicode.com',
services: [
_$PostApiService(),
],
// Our own converter for built values built on top of the default JsonConverter
converter: BuiltValueConverter(),
// Remove all interceptors from the previous part except for the HttpLoggingInterceptor
// which is always useful.
interceptors: [
HttpLoggingInterceptor(),
],
);
return _$PostApiService(client);
}
}
BuiltValueConverter
After all this setup comes the part of this tutorial you are the most interested in - how do you "connect" Chopper and BuiltValue to work together? By creating a BuiltValueConverter.
We won't have to build it from scratch though, as we will utilize the binary data to dynamic Map/List conversion which the default JsonConverter provides. Still, there's a lot of coding ahead! Let's first override convertRequest method as it's a lot less tricky than convertResponse.
Of course, we are building this code to work generically with all the classes implementing Built, not just the BuiltPost.
Request conversion
built_value_converter.dart
import 'package:chopper/chopper.dart';
import 'package:built_collection/built_collection.dart';
import 'package:retrofit_prep/model/serializers.dart';
class BuiltValueConverter extends JsonConverter {
@override
Request convertRequest(Request request) {
return super.convertRequest(
request.replace(
// request.body is of type dynamic, but we know that it holds only BuiltValue classes (BuiltPost).
// Before sending the request to the network, serialize it to a List/Map using a BuiltValue serializer.
body: serializers.serializeWith(
// Since convertRequest doesn't have a type parameter, Serializer's type will be determined at runtime
serializers.serializerForType(request.body.runtimeType),
request.body,
),
),
);
}
}
Request classes do not (yet) use generic type parameters for their bodies, hence they're dynamic. We know, however, that the body of a request will always be an instance of BuiltPost or some other Built class, should we add one. Before sending out the request, we have to serialize the BuiltPost body to a Map which will subsequently get converted to JSON by Chopper.
serializers.serialize(request.body)
Response conversion
Converting dynamic responses which contain either a List of Maps or just a Map itself is a bit of a tougher task. We will have to differentiate between the cases of deserializing a List and a single Map. Also, what if we explicitly set a method in the ChopperService to return a Map? You know, we might not always want to use BuiltValue data classes...
To accomplish all of this while keeping the code clean, we will separate the conversion and deserialization into multiple methods. Also, as opposed to convertRequest, convertResponse does have type parameters, so we won't have to determine anything at runtime.
Response<BodyType> convertResponse<BodyType, SingleItemType>
- BodyType will be a BuiltValue class, in our case it's either BuiltPost or BuiltList<BuiltPost>.
- If a body of the response contains only a single object, BodyType and SingleItemType will be identical.
- If the body contains a list of objects, Chopper will set the SingleItemType to be, well, the type which the list contains.
built_value_converter.dart
...
class BuiltValueConverter extends JsonConverter {
...
@override
Response<BodyType> convertResponse<BodyType, SingleItemType>(
Response response) {
// The response parameter contains raw binary JSON data by default.
// Utilize the already written code which converts this data to a dynamic Map or a List of Maps.
final Response dynamicResponse = super.convertResponse(response);
// customBody can be either a BuiltList<SingleItemType> or just the SingleItemType (if there's no list).
final BodyType customBody =
_convertToCustomObject<SingleItemType>(dynamicResponse.body);
// Return the original dynamicResponse with a no-longer-dynamic body type.
return dynamicResponse.replace<BodyType>(body: customBody);
}
dynamic _convertToCustomObject<SingleItemType>(dynamic element) {
// If the type which the response should hold is explicitly set to a dynamic Map,
// there's nothing we can convert.
if (element is SingleItemType) return element;
if (element is List)
return _deserializeListOf<SingleItemType>(element);
else
return _deserialize<SingleItemType>(element);
}
BuiltList<SingleItemType> _deserializeListOf<SingleItemType>(
List dynamicList,
) {
// Make a BuiltList holding individual custom objects
return BuiltList<SingleItemType>(
dynamicList.map((element) => _deserialize<SingleItemType>(element)),
);
}
SingleItemType _deserialize<SingleItemType>(
Map<String, dynamic> value,
) {
// We have a type parameter for the BuiltValue type
// which should be returned after deserialization.
return serializers.deserializeWith<SingleItemType>(
serializers.serializerForType(SingleItemType),
value,
);
}
}
Quite a lot of code, I know. Remember though, that once you write a converter like this which works with Chopper, you actually spare yourself of a lot of boilerplate in the long run.
Let's now change the UI part of the app code to use Chopper with type safety!
Updating the UI widgets
The look of the app will remain unchanged. We only want to finally use the BuiltPost data class instead of dynamic data.
In the HomePage, we perform a POST request from the floating action button. Instead of a Map, we can now pass a BuiltPost object into the request.
home_page.dart
...
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () async {
// Use BuiltPost even for POST requests
final newPost = BuiltPost(
(b) => b
// id is null - it gets assigned in the backend
..title = 'New Title'
..body = 'New body',
);
// The JSONPlaceholder API always responds with whatever was passed in the POST request
final response =
await Provider.of<PostApiService>(context).postPost(newPost);
// We cannot really add any new posts using the placeholder API,
// so just print the response to the console
print(response.body);
},
),
...
As for the GET request on the /posts endpoint, we will change the type parameter of the FutureBuilder and also ditch the unsafe accessors of a dynamic map in favor of regular accessors of an object. Also, now we don't have to sprinkle JSON conversion logic throughout our UI, which is definitely a good thing.
home_page.dart
import 'package:flutter/material.dart';
import 'package:chopper/chopper.dart';
import 'package:provider/provider.dart';
import 'package:built_collection/built_collection.dart';
import 'data/post_api_service.dart';
import 'model/built_post.dart';
import 'single_post_page.dart';
class HomePage extends StatelessWidget {
...
FutureBuilder<Response> _buildBody(BuildContext context) {
// Specify the type held by the Response
return FutureBuilder<Response<BuiltList<BuiltPost>>>(
future: Provider.of<PostApiService>(context).getPosts(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
// Exceptions thrown by the Future are stored inside the "error" field of the AsyncSnapshot
if (snapshot.hasError) {
return Center(
child: Text(
snapshot.error.toString(),
textAlign: TextAlign.center,
textScaleFactor: 1.3,
),
);
}
//* Body of the response is now type-safe and of type BuiltList<BuiltPost>.
final posts = snapshot.data.body;
return _buildPosts(context, posts);
} else {
// Show a loading indicator while waiting for the posts
return Center(
child: CircularProgressIndicator(),
);
}
},
);
}
// Changed the parameter type.
ListView _buildPosts(BuildContext context, BuiltList<BuiltPost> posts) {
return ListView.builder(
itemCount: posts.length,
padding: EdgeInsets.all(8),
itemBuilder: (context, index) {
return Card(
elevation: 4,
child: ListTile(
title: Text(
posts[index].title,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(posts[index].body),
onTap: () => _navigateToPost(context, posts[index].id),
),
);
},
);
}
...
}
As for the page displaying a single post, the changes will be similar to the ones above. Just don't use BuiltList<BuiltPost> but only a BuiltPost instead.
single_post_page.dart
import 'package:chopper/chopper.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'data/post_api_service.dart';
import 'model/built_post.dart';
class SinglePostPage extends StatelessWidget {
final int postId;
const SinglePostPage({
Key key,
this.postId,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Chopper Blog'),
),
body: FutureBuilder<Response<BuiltPost>>(
future: Provider.of<PostApiService>(context).getPost(postId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
final post = snapshot.data.body;
return _buildPost(post);
} else {
return Center(
child: CircularProgressIndicator(),
);
}
},
),
);
}
Padding _buildPost(BuiltPost post) {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: <Widget>[
Text(
post.title,
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 8),
Text(post.body),
],
),
);
}
}
Conclusion
Adding type safety to Chopper is probably the most challenging task regarding this library. In this tutorial, you learned how to integrate Built Value together with Chopper to make your code more robust and easier to read. Consider subscribing and joining the newsletter if you want to grow your Flutter skills!
Another great tutorial. Thank you. I learnt a non documented library, also I realized the importance of built packages.
This is great! However, I’m having trouble with errors using this approach. The Chopper service is expecting a Response with the type of the built value, but is getting a Response in the case of an error response. Any suggestions?
hi, thank you for your great tutorials. but I have a question here. imagine in a scenario we have two objects that the second one is used as a type for one of the fields in the first one. for using built_value we have to use abstract class, but we can’t use it as a type! what should we do here?
Hi @matej,
I appreciate the great post.
How would i write a custom serializer for a scenario like this one:
In an api response, a certain object returns this dictionary: “Images”: { “Key1”: “value1”, “Key2”: “value2”, “Key3”: “value3” }
And another object returns; “Images”: { “Key1”: “value1”, “Key2”: “value2”, “Key3”: “value3”, “Key4”: “value4” },
And both objects are supposed to be deserialized using the same method for automation to be possible.
I know a foreach loop would deserialize the data properly but I am failing to write a custom Serializer to fit this purpose.
Thanks for the help in advance
Hi Matt Rešetár!
Great tutorial! I only have a small suggestion:
I’ve run into some problems when deserializing a list of integers using your BuiltValueConverter. The problem is that your _deserialize method expects a Map, and when the code processes a list of primitives, like integers, this fails.
So to process responses like [1, 2, 3, 4, 5], we need to modify your _deserializeListOf() method a little bit, and add a safe-check to make sure that our list-elements are not already in the state we want them to be.
So this is how I modified your code to make it more generic:
dynamicList.map((element) {
if (element is SingleItemType) return element;
return _deserialize(element);
}),
This will instantly return primitive items, without trying to call the _deserialize() method on them.
https://ngdeveloper.com/how-to-convert-objects-to-json-string-in-dart-flutter/
What is BuiltList?
I am getting The argument type ‘Serializer?’ can’t be assigned to the parameter type ‘Serializer’. when i am creating the json custom converter for built value. I tried your code but i am getting this error. Please guide.
import ‘package:chopper/chopper.dart’;
import ‘package:chopper_project/serializers.dart’;
class BuiltValueConverter extends JsonConverter {
@override
Request convertRequest(Request request) {
final requestToReturn = request.copyWith(
body: serializers.serializeWith(
serializers.serializerForType(request.body.runtimeType),
request.body,
),
);
return super.convertRequest(requestToReturn);
}
}
Try:
serializers.serializerForType(request.body.runtimeType)!
How can we send list of int in request?