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:
- Created an
abstract class
. It’s a simple blueprint for a result with a generic typeT
. - Created the
Success
class to extendResult
and hold a value when the response is successful. This could hold JSON data, for example. - Created the
Error
class to extendResult
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
- This adds the Chopper package and your models.
- Here is where you re-enter your API Key and ID.
- 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() here
with:
// 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:
@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.RecipeService
is anabstract
class because you only need to define the method signatures. The generator script will take these definitions and generate all the code needed.@Get
is an annotation that tells the generator this is a GET request with apath
namedsearch
, which you previously removed from theapiUrl
. There are other HTTP methods you can use, such as@Post
,@Put
and@Delete
, but you won’t use them in this chapter.- You define a function that returns a
Future
of aResponse
using the previously createdAPIRecipeQuery
. The abstractResult
that you created above will hold either a value or an error. queryRecipes()
uses the Chopper@Query
annotation to accept aquery
string andfrom
andto
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:
- Use
ModelConverter
to implement the ChopperConverter
abstract class. - Override
convertRequest()
, which takes in a request and returns a new request. - Add a header to the request that says you have a request type of application/json using
jsonHeaders
. These constants are part of Chopper. - 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:
- Extract the content type from the request headers.
- Confirm
contentType
is of typeapplication/json
. - 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:
- Check that you’re dealing with JSON and decode the
response
into a string namedbody
. - Use JSON decoding to convert that string into a map representation.
- 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 ofError
. - Use
APIRecipeQuery.fromJson()
to convert the map into the model class. - Return a successful response that wraps
recipeQuery
. - 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);
}
- 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:
- Creates a
Map
, which contains key-value pairs from the existingRequest
parameters. - Adds the
app_id
and theapp_key
parameters to the map. - 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:
- Create a
ChopperClient
instance. - Pass in a base URL using the
apiUrl
constant. - 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. - Set the
converter
as an instance ofModelConverter
. - Use the built-in
JsonConverter
to decode any errors. - Define the services created when you run the generator script.
- 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 part
keyword. 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 import
statements 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:
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.
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
toWARNING
,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:
- Check to see if the call was successful.
- Check for an error map and extract the message to show.
snapshot.data
is now aResponse
and not a string anymore. Thebody
field is either theSuccess
orError
that you defined above. Extract the value ofbody
intoresult
.- If
result
is an error, return the current list of recipes. - Since
result
passed the error check, cast it asSuccess
and extract its value intoquery
.
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.
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!