跳转至

12 Using a Network LibraryWritten by Kevin D Moore

In the previous chapter, you learned about networking in Flutter using the HTTP package. Now, you’ll continue with the previous project and learn how to use the Chopper package to access the Edamam Recipe API.

Note: You can also start fresh by opening this chapter’s starter project. If you choose to do this, remember to click the Pub Get button or execute flutter pub get from Terminal. You’ll also need your API Key and ID.

By the end of the chapter, you’ll know:

  • How to set up Chopper and use it to fetch data from a server API.
  • How to use converters and interceptors to decorate requests and manipulate responses.
  • How to log requests.

Why Chopper?

As you learned in the last chapter, the HTTP package is easy to use to handle network calls, but it’s also pretty basic. Chopper does a lot more. For example:

  • It generates code to simplify the development of networking code.
  • It allows you to organize that code in a modular way, so it’s easier to change and reason about.

Note: If you come from the Android side of mobile development, you’re probably familiar with the Retrofit library, which is similar. If you have an iOS background, AlamoFire is a very similar library.

Preparing to use Chopper

To use Chopper, you need to add the package to pubspec.yaml. To log network calls, you also need the logging package.

Open pubspec.yaml and add the following after the http package:

chopper: ^4.0.6
logging: ^1.0.2

You also need chopper_generator, which is a package that generates the boilerplate code for you in the form of a part file. In the dev_dependencies section, after json_serializable, add this:

chopper_generator: ^4.0.6

Next, either click Pub get or run flutter pub get in Terminal to get the new packages.

Now that the new packages are ready to be used… fasten your seat belt! :]

Handling recipe results

In this scenario, it’s a good practice to create a generic response class that will hold either a successful response or an error. While these classes aren’t required, they make it easier to deal with the responses that the server returns.

Right-click on lib/network and create a new Dart file named model_response.dart. Add the following classes to it:

// 1
abstract class Result<T> {
}

// 2
class Success<T> extends Result<T> {
  final T value;

  Success(this.value);
}

// 3
class Error<T> extends Result<T> {
  final Exception exception;

  Error(this.exception);
}

Here, you’ve:

  1. Created an abstract class. It’s a simple blueprint for a result with a generic type T.
  2. Created the Success class to extend Result and hold a value when the response is successful. This could hold JSON data, for example.
  3. Created the Error class to extend Result and hold an exception. This will model errors that occur during an HTTP call, like using the wrong credentials or trying to fetch data without authorization.

Note: To refresh your knowledge of abstract classes in Dart, check out our Dart Apprentice book https://www.raywenderlich.com/books/dart-apprentice/.

You’ll use these classes to model the data fetched via HTTP using Chopper.

Preparing the recipe service

Open recipe_service.dart. You need to have your API Key and ID for this next step.

Delete all the code in the file and add the following, making sure to re-enter your API Key and ID:

// 1
import 'package:chopper/chopper.dart';
import 'recipe_model.dart';
import 'model_response.dart';
import 'model_converter.dart';

// 2
const String apiKey = '<Your Key Here>';
const String apiId = '<Your Id here>';
// 3
const String apiUrl = 'https://api.edamam.com';

// TODO: Add @ChopperApi() here
  1. This adds the Chopper package and your models.
  2. Here is where you re-enter your API Key and ID.
  3. The /search was removed from the URL so that you can call other APIs besides /search.

Note: If you’re using the project from the last chapter, remove /search from the apiUrl.

It’s now time to set up Chopper!

Setting up the Chopper client

Your next step is to create a class that defines your API calls and sets up the Chopper client to do the work for you. Still in recipe_service.dart, replace // TODO: Add @ChopperApi() herewith:

// 1
@ChopperApi()
// 2
abstract class RecipeService extends ChopperService {
  // 3
  @Get(path: 'search')
  // 4
  Future<Response<Result<APIRecipeQuery>>> queryRecipes(
    // 5
      @Query('q') String query, @Query('from') int from, @Query('to') int to);
  // TODO: Add create()
}
// TODO: Add _addQuery()

There’s quite a lot to understand here. To break it down:

  1. @ChopperApi() tells the Chopper generator to build a part file. This generated file will have the same name as this file, but with .chopper added to it. In this case, it will be recipe_service.chopper.dart. Such a file will hold the boilerplate code.
  2. RecipeService is an abstract class because you only need to define the method signatures. The generator script will take these definitions and generate all the code needed.
  3. @Get is an annotation that tells the generator this is a GET request with a path named search, which you previously removed from the apiUrl. There are other HTTP methods you can use, such as @Post, @Put and @Delete, but you won’t use them in this chapter.
  4. You define a function that returns a Future of a Response using the previously created APIRecipeQuery. The abstract Result that you created above will hold either a value or an error.
  5. queryRecipes() uses the Chopper @Query annotation to accept a query string and from and to integers. This method doesn’t have a body. The generator script will create the body of this function with all the parameters.

Notice that, so far, you have defined a generic interface to make network calls. There’s no actual code that performs tasks like adding the API key to the request or transforming the response into data objects. This is a job for converters and interceptors!

Converting request and response

To use the returned API data, you need a converter to transform requests and responses. To attach a converter to a Chopper client, you need an interceptor. You can think of an interceptor as a function that runs every time you send a request or receive a response — a sort of hook to which you can attach functionalities, like converting or decorating data, before passing such data along.

Right-click on lib/network, create a new file named model_converter.dart and add the following:

import 'dart:convert';
import 'package:chopper/chopper.dart';
import 'model_response.dart';
import 'recipe_model.dart';

This adds the built-in Dart convert package, which transforms data to and from JSON, plus the Chopper package and your model files.

Next, create ModelConverter by adding:

// 1
class ModelConverter implements Converter {
  // 2
  @override
  Request convertRequest(Request request) {
    // 3
    final req = applyHeader(
      request,
      contentTypeKey,
      jsonHeaders,
      override: false,
    );

    // 4
    return encodeJson(req);
  }

  Request encodeJson(Request request) {}

  Response decodeJson<BodyType, InnerType>(Response response) {}

  @override
  Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {}
}

Here’s what you’re doing with this code:

  1. Use ModelConverter to implement the Chopper Converter abstract class.
  2. Override convertRequest(), which takes in a request and returns a new request.
  3. Add a header to the request that says you have a request type of application/json using jsonHeaders. These constants are part of Chopper.
  4. Call encodeJson() to convert the request to a JSON-encoded one, as required by the server API.

The remaining code consists of placeholders, which you’ll include in the next section.

Encoding and decoding JSON

To make it easy to expand your app in the future, you’ll separate encoding and decoding. This gives you flexibility if you need to use them separately later.

Whenever you make network calls, you want to ensure that you encode the request before you send it and decode the response string into your model classes, which you’ll use to display data in the UI.

Encoding JSON

To encode the request in JSON format, replace the existing encodeJson() with:

Request encodeJson(Request request) {
  // 1
  final contentType = request.headers[contentTypeKey];
  // 2
  if (contentType != null && contentType.contains(jsonHeaders)) {
    // 3
    return request.copyWith(body: json.encode(request.body));
  }
  return request;
}

In this code, you:

  1. Extract the content type from the request headers.
  2. Confirm contentType is of type application/json.
  3. Make a copy of the request with a JSON-encoded body.

Essentially, this method takes a Request instance and returns a encoded copy of it, ready to be sent to the server. What about decoding? Glad you asked. :]

Decoding JSON

Now, it’s time to add the functionality to decode JSON. A server response is usually a string, so you’ll have to parse the JSON string and transform it into the APIRecipeQuery model class.

Replace decodeJson() with:

Response<BodyType> decodeJson<BodyType, InnerType>(Response response) {
  final contentType = response.headers[contentTypeKey];
  var body = response.body;
  // 1
  if (contentType != null && contentType.contains(jsonHeaders)) {
    body = utf8.decode(response.bodyBytes);
  }
  try {
    // 2
    final mapData = json.decode(body);
    // 3
    if (mapData['status'] != null) {
      return response.copyWith<BodyType>(
          body: Error(Exception(mapData['status'])) as BodyType);
    }
    // 4
    final recipeQuery = APIRecipeQuery.fromJson(mapData);
    // 5
    return response.copyWith<BodyType>(
        body: Success(recipeQuery) as BodyType);
  } catch (e) {
    // 6
    chopperLogger.warning(e);
    return response.copyWith<BodyType>(
        body: Error(e as Exception) as BodyType);
  }
}

There’s a lot to think about here. To break it down, you:

  1. Check that you’re dealing with JSON and decode the response into a string named body.
  2. Use JSON decoding to convert that string into a map representation.
  3. When there’s an error, the server returns a field named status. Here, you check to see if the map contains such a field. If so, you return a response that embeds an instance of Error.
  4. Use APIRecipeQuery.fromJson() to convert the map into the model class.
  5. Return a successful response that wraps recipeQuery.
  6. If you get any other kind of error, wrap the response with a generic instance of Error.

You still have to override one more method: convertResponse(). This method changes the given response to the one you want.

Replace the existing convertResponse() with:

@override
Response<BodyType> convertResponse<BodyType, InnerType>(Response response) {
  // 1
  return decodeJson<BodyType, InnerType>(response);
}
  1. This simply calls decodeJson, which you defined earlier.

Now it’s time to use the converter in the appropriate spots and to add some interceptors.

Using interceptors

As mentioned earlier, interceptors can intercept either the request, the response or both. In a request interceptor, you can add headers or handle authentication. In a response interceptor, you can manipulate a response and transform it into another type, as you’ll see shortly. You’ll start with decorating the request.

Automatically including your ID and key

To request any recipes, the API needs your app_id and app_key. Instead of adding these fields manually to each query, you can use an interceptor to add them to each call.

Open recipe_service.dart and replace // TODO: Add _addQuery() with:

Request _addQuery(Request req) {
  // 1
  final params = Map<String, dynamic>.from(req.parameters);
  // 2
  params['app_id'] = apiId;
  params['app_key'] = apiKey;
  // 3
  return req.copyWith(parameters: params);
}

This is a request interceptor that adds the API key and ID to the query parameters. Here’s what the code does:

  1. Creates a Map, which contains key-value pairs from the existing Request parameters.
  2. Adds the app_id and the app_key parameters to the map.
  3. Returns a new copy of the Request with the parameters contained in the map.

The benefit of this method is that, once you hook it up, all your calls will use it. While you only have one call for now, if you add more, they’ll include those keys automatically. And if you want to add a new parameter to every call you’ll change only this method. Are you starting to see the advantages of Chopper? :]

You have interceptors to decorate requests and you have a converter to transform responses into model classes. Next, you’ll put them to use!

Wiring up interceptors & converters

It’s time to create an instance of the service that will fetch recipes.

Still in recipe_service.dart, locate // TODO: Add create() and replace it with the following code. Don’t worry about the red squiggles; they’re warning you that the boilerplate code is missing, because you haven’t generated it yet.

static RecipeService create() {
  // 1
  final client = ChopperClient(
    // 2
    baseUrl: apiUrl,
    // 3
    interceptors: [_addQuery, HttpLoggingInterceptor()],
    // 4
    converter: ModelConverter(),
    // 5
    errorConverter: const JsonConverter(),
    // 6
    services: [
      _$RecipeService(),
    ],
  );
  // 7
  return _$RecipeService(client);
}

In this code, you:

  1. Create a ChopperClient instance.
  2. Pass in a base URL using the apiUrl constant.
  3. Pass in two interceptors. _addQuery() adds your key and ID to the query. HttpLoggingInterceptor is part of Chopper and logs all calls. It’s handy while you’re developing to see traffic between the app and the server.
  4. Set the converter as an instance of ModelConverter.
  5. Use the built-in JsonConverter to decode any errors.
  6. Define the services created when you run the generator script.
  7. Return an instance of the generated service.

It’s all set, you are ready to generate the boilerplate code!

Generating the Chopper file

Your next step is to generate recipe_service.chopper.dart, which works with the partkeyword. Remember from Chapter 10, “Serialization With JSON”, part will include the specified file and make it part of one big file.

Import the file that you’ll generate. Still in recipe_service.dart, add this after the importstatements at the top:

part 'recipe_service.chopper.dart';

Ignore the red squiggles. They’ll disappear after you’ve generated the file.

Note: It might seem weird to import a file before it’s been created but the generator script will fail if it doesn’t know what file to create.

Now, open Terminal in Android Studio. By default, it will be in your project folder. Execute:

flutter pub run build_runner build --delete-conflicting-outputs

Note: Using --delete-conflicting-outputs will delete all generated files before generating new ones.

While it’s executing, you’ll see something like this:

img

Once it finishes, you’ll see the new recipe_service.chopper.dart in lib/network. You may need to refresh the network folder before it appears.

img

Note: In case you don’t see the file or Android Studio doesn’t detect its presence, restart Android Studio.

Open it and check it out. The first thing you’ll see is a comment stating not to modify the file by hand.

Looking farther down, you’ll see a class called _$RecipeService. Below that, you’ll notice that queryRecipes() has been overridden to build the parameters and the request. It uses the client to send the request.

It may not seem like much, but as you add different calls with different paths and parameters, you’ll start to appreciate the help of a code generator like the one included in Chopper. :]

Now that you’ve changed RecipeService to use Chopper, it’s time to put on the finishing touches: Set up logging and use the new method to fetch data.

Logging requests & responses

Open main.dart and add the following import:

import 'dart:developer';
import 'package:logging/logging.dart';

This is from the logging package you added to pubspec.yaml earlier.

Locate // TODO: Add _setupLogging() and replace it with:

void _setupLogging() {
  Logger.root.level = Level.ALL;
  Logger.root.onRecord.listen(
    (rec) {
      log('${rec.level.name}: ${rec.time}: ${rec.message}');
    },
  );
}

This initializes the logging package and allows Chopper to log requests and responses. Set the level to Level.ALL so that you see every log statement.

Note: You can try changing ALL to WARNING, SEVERE or one of the other levels to see what happens.

Now, replace // TODO: Call _setupLogging() with:

_setupLogging();

Logging is all set now. It’s time to use the new functionalities based on Chopper :]

Using the Chopper client

Open ui/recipes/recipe_list.dart. You’ll see some errors due to the changes you’ve made.

If you see the following import delete it, as it’s already imported in other classes:

import 'dart:convert';

Now, add the following imports:

import 'package:chopper/chopper.dart';
import '../../network/model_response.dart';
import 'dart:collection';

Find // TODO: Delete getRecipeData() and delete it and getRecipeData().

In _buildRecipeLoader(), replace // TODO: change with new response and the line below it from:

return FutureBuilder<APIRecipeQuery>(

to:

return FutureBuilder<Response<Result<APIRecipeQuery>>>(

This uses the new response type that wraps the result of an API call.

Now, replace the future below // TODO: change with new RecipeService with:

future: RecipeService.create().queryRecipes(
    searchTextController.text.trim(),
    currentStartPosition,
    currentEndPosition),

The future now creates a new instance of RecipeService and calls its method, queryRecipes(), to perform the query.

Finally, replace the line below // TODO: change with new snapshot from:

final query = snapshot.data;

to:

// 1
if (false == snapshot.data?.isSuccessful) {
  var errorMessage = 'Problems getting data';
  // 2
  if (snapshot.data?.error != null &&
      snapshot.data?.error is LinkedHashMap) {
    final map = snapshot.data?.error as LinkedHashMap;
    errorMessage = map['message'];
  }
  return Center(
    child: Text(
      errorMessage,
      textAlign: TextAlign.center,
      style: const TextStyle(fontSize: 18.0),
    ),
  );
}
// 3
final result = snapshot.data?.body;
if (result == null || result is Error) {
  // Hit an error
  inErrorState = true;
  return _buildRecipeList(context, currentSearchList);
}
// 4
final query = (result as Success).value;

Here’s what you did in the code above:

  1. Check to see if the call was successful.
  2. Check for an error map and extract the message to show.
  3. snapshot.data is now a Response and not a string anymore. The body field is either the Success or Error that you defined above. Extract the value of body into result.
  4. If result is an error, return the current list of recipes.
  5. Since result passed the error check, cast it as Success and extract its value into query.

Stop the app, run it again and choose the search value chicken from the drop-down button. Verify that you see the recipes displayed in the UI.

img

Now, look in the Run window of Android Studio, where you’ll see lots of [log] INFO messages related to your network calls. This is a great way to see how your requests and responses look and to figure out what’s causing any problems.

You made it! You can now use Chopper to make calls to the server API and retrieve recipes.

Key points

  • The Chopper package provides easy ways to retrieve data from the internet.
  • You can add headers to each network request.
  • Interceptors can intercept both requests and responses and change those values.
  • Converters can modify requests and responses.
  • It’s easy to set up global logging.

Where to go from here?

If you want to learn more about the Chopper package, go to https://pub.dev/packages/chopper. For more info on the Logging library, visit https://pub.dev/packages/logging.

In the next chapter, you’ll learn about the important topic of state management. Until then!