2

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

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!

Matej Rešetár
 

Matej is an app developer with a knack for teaching others. If he's not programming, making tutorials or doing other business, he's mostly working out, listening to audiobooks and taking cold showers.

  • DIMITRIS TOTSIOS says:

    Another great tutorial. Thank you. I learnt a non documented library, also I realized the importance of built packages.

  • Peter Bryant says:

    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?

  • >