Chopper (Retrofit para Flutter) #3 – Conversores e Integración con Built Value

Este tutorial y el resto de la serie son una traducción del genial trabajo de Matej Rešetár. Puedes leer el original aquí o si lo prefieres puedes ver el vídeo.

Ya conoces casi todo lo que Chopper puede ofrecer para las tareas básicas del día a día. Enviar peticiones, obtener respuestas, añadir interceptores… Aún nos queda una cosa por ver que llegará hondo al corazón de un “clean coder” – tipado seguro.

JSON no tiene tipado seguro, es algo con lo que tenemos que vivir. Sin embargo, podemos hacer nuestro código mucho más legible y menos propenso a errores si pasamos de datos dinámicos a un tipado más real. Chopper ofrece una manera estupenda para convertir los datos de las peticiones y las respuestas, con la ayuda de  una librería externa. Para este tutorial lo haremos con built_value.

Built Value es posiblemente la mejor opción cuando queremos crear clases de datos inmutables.

Haciendo una data class

El primer paso para escribir código con tipado seguro es, obviamente, añadir tipos. Dado que vamos a utilizar built_value, primero añadamos este package en nuestras dependencias.

pubspec.yaml

dependencies:
  ...
  built_value: ^6.7.0

dev_dependencies:
  ...
  built_value_generator: ^6.7.0

A lo largo de esta serie de tutorials hemos creado una app para ver posts de JSON Placeholder API. Si necesitas recordar la estructura del JSON que recibimos en la respuesta, aquí está:

GET /posts

[
  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipitnsuscipit recusandae consequuntur expedita et cumnreprehenderit molestiae ut ut quas totamnnostrum rerum est autem sunt rem eveniet architecto"
  },

...

]

Creando una clase BuiltPost

Nos interesan todos los campos menos userId. Creemos ahora una clase BuiltPost que contenga todos esos datos. Del mismo modo que Chopper, Built Value también utiliza generación de código, por tanto crearemos un nuevo archivo built_post.dart y la implementación real estará dentro del archivo generado built_post.g.dart. Para mantener nuestro código organizado pondremos todo esto en una nueva carpeta que llamaremos model.

Snippets para built_value aquí

///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;
}

Ésta es una clase de datos Built Value bastante estándar, pero en el caso en el que no hubieras visto aún el tutorial sobre built_value (¡deberías!), aquí te dejo un resumen rápido:

Los campos son propiedades de solo-lectura – los datos realmente se almacenarán en la clase generada.

El constructor por defecto es privado, en su lugar el builder usará un factory.

Los valores de los campos se setean desde el builder.

Al especificar una propiedad del Serializer generamos una clase _$BuiltPostSerializer, que es la que se usará para convertir esos feos datos dinámicos en una bonita clase de datos BuildPost.

Añadiendo BuiltPost a la lista de serializadores globales

Sí, una vez que generamos el código tendremos un serializador para las clases BuiltPost. Es un paso importante pero no es suficiente. Existe un ecosistema de serializadores para tipos como integer, String, bool y otros primitivos.

Para poder serializar y deserializar BuiltPost con éxito, nuestra app tendrá que usarlo en conjunto con otros serializadores. Podemos conseguir esto añadiendo el serializador de BuiltPost a la lista de serializadores que built_value ofrece.

///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();

Asegúrate de añadir el StandardJsonPlugin donde quieras usar el JSON generado con una API RESTful. Por defecto, la salida JSON de BuiltValue no son valores clave-valor, en vez de eso retorna una lista que contiene [key1, value1, key2, value2, …]. Esto no es lo que la mayoría de las APIS esperan.

Generando el código

Como siempre, para iniciar la generación de código ejecuta el siguiente comando en el terminal. Usaremos el modificador watch para que el comando se relance cuando cambiemos la implementación de PostApiService.

flutter packages pub run build_runner watch

Actualizando PostApiService

Queremos que los métodos de PostApiService devuelvan Respuestas que contengan una lista (BuiltList en este caso) de BuiltPosts, o simplemente un BuiltPost. Para posibilitar la conversión a clases BuiltValue, a continuación crearemos un BuiltValueConverter. Aunque aún no existe, sustituyamos el JsonConverter por defecto por él, de modo que no tengamos que volver a este archivo.

/// 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

Después de toda esta configuración viene la parte del tutorial más interesante – ¿cómo conectar Chopper y BuiltValue para que trabajen juntos? Bien, esto lo haremos creando un BuiltValueConverter.

No tendremos que implementarlo desde cero, ya que utilizaremos la conversión de datos binarios a Map/List dinámicos que el JsonConverter por defecto nos proporciona. No obstante, todavía queda mucho código por escribir.

Primero sobrescribiremos el método convertRequest ya que es mucho menos complicado que convertResponse.

Por supuesto, éste código funcionará de manera genérica para todas las clases Built que implementemos, no solo BuiltPost.

Conversión de la petición

///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,
        ),
      ),
    );
  }
}

La clase Request aún no utiliza parámetros con tipos genéricos para el body (son dinámicos). Sabemos, sin embargo, que el body de una petición siempre será una instancia de BuiltPost o cualquier otra Built class. Antes de enviar la petición tenemos que serializar el body del BuiltPost a un Map que después será convertido a JSON por Chopper.

También podríamos dejar que BuiltValue determine por sí mismo el tipo del serializador:

serializers.serialize(request.body)

pero esto inflaría el body de la petición al añadirle datos innecesarios relacionados con el tipo.

Conversión de la respuesta

Convertir respuestas dinámicas que contienen o bien una Lista de Maps, o simplemente un Map es una tarea un tanto pesada. Tendremos que diferenciar entre deserializer una List y un Map simple. Por tanto, ¿qué tal si establecemos explícitamente un método en el ChopperService que devuelva un Map? Podría ocurrir que no siempre queramos utilizar BuiltValue data classes…

Para llevar a cabo todo esto manteniendo limpio nuestro código separaremos la conversión y la deserialización en varios métodos. Además, al contrario que convertRequest, convertResponse tiene parámetros tipados, por tanto no tendremos que determinar nada en tiempo de ejecución.

Antes de añadir todo el código, estos parámetros tipados requieren una breve explicación. La definición de convertResponse es la siguiente:

Response<BodyType> convertResponse<BodyType, SingleItemType>
  • BodyType es una clase BuiltValue, en nuestro caso o BuiltPost o BuiltList.
  • Si el body de la respuesta contiene solo un objeto simple, BodyType y SingleItemType serán idénticos.
  • Si el body contiene una lista de objetos, Chopper establecerá que el SingleItemType sea, bien, el tipo que la lista contiene.
///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,
    );
  }
}

Es bastante código, lo sé. Recuerda sin embargo, que una vez hecho un conversor como éste que funcione con Chopper, nos libramos de un montón de código repetitivo a largo plazo.

¡Cambiemos ahora la parte de la UI del cadge de la app para usar Chopper tipado seguro!

Actualizando los widgets

El aspecto de la app permanecerá inalterado. Nosotros solo queremos usar la clase Built Post en vez de datos dinámicos.

alt desc

En la HomePage, llevamos a cabo una petición POST al pulsar el floating action button. En vez de un Map, ahora podemos pasar en la petición un objeto BuiltPost.

///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);
  },
),

...

En cuanto a la petición GET en el endpoint /posts, cambiaremos el tipo del parámetro del FutureBuilder y también nos desharemos de los accesos inseguros de un map dinámico en favor de los accesos regulados de un objeto. Además, ahora no tenemos que dejar lógica para la conversión del JSON por nuestra UI, algo que es, definitivamente, bueno.

///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),
          ),
        );
      },
    );
  }

  ...

}

En cuanto a la página que muestra el detalle de un post, los cambios serán similares a los anteriores. Simplemente utilizaremos BuiltPost en vez de usar BuiltList.

///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),
        ],
      ),
    );
  }
}

Conclusión

Añadir topado seguro a Chopper es probablemente la tarea más desafiante con lo que respecta a esta librería. En este tutorial aprendimos como integrar Built Value con Chopper para hacer nuestro código más robusto y legible.

Si te gustó el contenido y la traducción puedes hacer fav y compartir en mi cuenta de dev.to.