Chopper (Retrofit for Flutter) #3 – Converters & Built Value Integration

5  comments

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();
Make sure you add the StandardJsonPlugin whenever you want to use the generated JSON with a RESTful API. By default, BuiltValue's JSON output aren't key-value pairs, but instead a list containing [key1, value1, key2, value2, ...]. This is not what most of the APIs expect.

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.

flutter packages pub run build_runner watch

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.

You could also let BuiltValue determine the serializer's type itself by calling
serializers.serialize(request.body)
but that would bloat the request body by adding unnecessary type-related data to it. 

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.

Before we add all of the code though, those type parameters require a brief explanation. The definition of convertResponse is the following:
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 Built​Post data class instead of dynamic data.

UI remains unchanged

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!

About the author 

Matt Rešetár

Matt is an app developer with a knack for teaching others. Working as a Flutter freelancer and most importantly developer educator, he doesn't have a lot of free time 😅 Yet he still manages to squeeze in tough workouts 💪 and guitar 🎸

You may also like

Flutter Custom & Staggered Page Transition Animation Tutorial

Flutter Firebase & DDD Course [5] – Sign-In Form Logic

  • 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.

  • {"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}
    >