11.
Serialization With JSON
Written by Kevin D Moore
In this chapter, you’ll learn how to serialize JSON data into model classes. A model class represents data structure and defines attributes and operations for a particular object. An example is a recipe model class, which usually has a title, an ingredient list and steps to cook it.
You’ll continue with the previous project, which is the starter project for this chapter. You’ll add a class that models a recipe, and its properties. Then, you’ll integrate that class into the existing project.
By the end of this chapter, you’ll know:
- How to serialize JSON into model classes.
- How to use Dart tools to automate the generation of model classes from JSON.
What is JSON?
JSON, which stands for JavaScript Object Notation, is an open-standard format used on the web and in mobile clients. It’s the most widely used format for Representational State Transfer (REST)-based APIs that servers provide (https://en.wikipedia.org/wiki/Representational_state_transfer). If you talk to a server that has a REST API, it will most likely return data in a JSON format. An example of a JSON response looks something like this:
{
"results": [
{
"id": 296687,
"title": "Chicken",
"image": "https://spoonacular.com/recipeImages/296687-312x231.jpeg",
"imageType": "jpeg"
},
...
]
}
That’s an example recipe response containing a list of results with four fields inside an object.
While it’s possible to treat the JSON as just a long string and try to parse out the data, it’s much easier to use a package that already knows how to do that. Flutter has a built-in package for decoding JSON, but in this chapter, you’ll use the json_serializable and json_annotation packages to help make the process easier.
Note: JSON parsing is the process of converting a JSON object in String format to a Dart object that can be used inside a program.
Flutter’s built-in dart:convert package contains methods like json.decode() and json.encode(), which converts a JSON string to a Map<String, dynamic> and back. While this is a step ahead of manually parsing JSON, you’d still have to write extra code that takes that map and puts the values into a new class.
The json_serializable package is useful because it can generate model classes for you according to the annotations you provide via json_annotation. Before taking a look at automated serialization, you need to see how to manually serialize JSON.
Writing the Code Yourself
So, how do you go about writing code to serialize JSON yourself? Typical model classes have toJson() and fromJson() methods. The toJson() method helps to convert objects into JSON strings, and the fromJson() method helps to parse a JSON string into an object so you can use it inside the program.
In the next section, you learn how to use automated serialization. For now, you don’t need to type this into your project, but you need to understand the methods to convert the JSON above to a model class.
First, you’d create a Recipe model class:
class Recipe {
final String uri;
final String label;
Recipe({this.uri, this.label});
}
Then you’d add a toJson() factory method and a fromJson() method:
factory Recipe.fromJson(Map<String, dynamic> json) {
return Recipe(json['uri'] as String, json['label'] as String);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{ 'uri': uri, 'label': label}
}
In fromJson(), you grab data from the JSON map variable named json and convert it to arguments you pass to the Recipe constructor. In toJson(), you construct a map using the JSON field names.
While it doesn’t take much effort to do that by hand for two fields, what if you had multiple model classes, each with, say, five fields, or more? What if you renamed one of the fields? Would you remember to rename all of the occurrences of that field?
The more model classes you have, the more complicated it becomes to maintain the code behind them. Fear not, that’s where automated code generation comes to the rescue.
Automating JSON Serialization
In this chapter, you’ll use two packages: json_annotation and json_serializable from Google.
You use the first to add annotations to model classes so that json_serializable can generate helper classes to convert a JSON string to a model and back.
To do that, you mark a class with the @JsonSerializable() annotation so the builder package can generate code for you. Each field in the class should either have the same name as the field in the JSON string, or use the @JsonKey() annotation to give it a different name.
Most builder packages work by generating a .part file. That will be a file that’s automatically created for you. All you need to do is add a few factory methods, which will call the generated code.
Note: The freezed package is also used in the project and uses the json_serializable package to generate serialization code. You could just use the freezed package by itself if you wanted to, as it has additional functionality.
Adding Dependencies for JSON Serialization and Deserialization
Continue with your current project, or open the starter project in the projects folder. Add the following package to pubspec.yaml in the Flutter dependencies section underneath and make sure it’s aligned with flutter_riverpod:
json_annotation: ^4.8.1
In the dev_dependencies section replace # TODO: Add new dev_dependencies with the following:
json_serializable: ^6.7.1
Make sure these are all indented correctly. build_runner, which is already included, is the package that helps generate the code.
Finally, click the Pub get button you should see at the top of the file, or run flutter pub get in the terminal. You’re now ready to generate model classes.
Generating Model Classes From JSON
The JSON that you’re trying to serialize looks something like this:
{
"results": [
{
"id": 296687,
"title": "Chicken",
"image": "https://spoonacular.com/recipeImages/296687-312x231.jpeg",
"imageType": "jpeg"
},
{
"id": 379523,
"title": "Chicken",
"image": "https://spoonacular.com/recipeImages/379523-312x231.jpeg",
"imageType": "jpeg"
},
...
],
"offset": 0,
"number": 10,
"totalResults": 51412
}
- The
resultslist is a list of recipe objects. - Each recipe has an
id,title,imageandimageType. -
offsetis the starting position for the search. 0 means start at the beginning, while a value of 10 would start at the 10th element. This is useful for paging long lists. -
numberis the total number of results returned in this list. -
totalResultsis the total results available for this search query.
Your next step is to generate the classes that model that data.
Creating Model Classes
Start by opening lib/network/spoonacular_model.dart and add the following import at the top:
import 'package:json_annotation/json_annotation.dart';
import '../data/models/models.dart';
part 'spoonacular_model.g.dart';
The json_annotation library lets you mark a class as serializable. The file spoonacular_model.g.dart doesn’t exist yet, you’ll generate it in a later step.
Next, replace // TODO: Add SpoonacularResults class with a class named SpoonacularResults with a @JsonSerializable() annotation:
@JsonSerializable()
class SpoonacularResults {
// TODO: Add Fields
// TODO: Add Constructor
// TODO: Add fromJson
// TODO: Add toJson
}
// TODO: Add SpoonacularResult
That marks the SpoonacularResults class as serializable so the json_serializable package can generate the corresponding .g.dart file.
Command-Click on JsonSerializable, scroll down, and you’ll see its definition:
...
/// Creates a new [JsonSerializable] instance.
const JsonSerializable({
@Deprecated('Has no effect') bool? nullable,
this.anyMap,
this.checked,
this.constructor,
this.createFieldMap,
this.createFactory,
this.createToJson,
this.disallowUnrecognizedKeys,
this.explicitToJson,
this.fieldRename,
this.ignoreUnannotated,
this.includeIfNull,
this.converters,
this.genericArgumentFactories,
this.createPerFieldToJson,
});
...
For example, you can make the class nullable and add extra checks for validating JSON properly. Close the json_serialization.dart source file after reviewing it.
Converting to and From JSON
Now, you need to add JSON conversion methods within the SpoonacularResults class. Return to spoonacular_model.dart and replace // TODO: Add Fields with:
List<SpoonacularResult> results;
int offset;
int number;
int totalResults;
This is the list of results, offset, number and total results. The SpoonacularResult class doesn’t exist yet. Next, replace // TODO: Add Constructor with:
SpoonacularResults({
required this.results,
required this.offset,
required this.number,
required this.totalResults,
});
The required annotation says that these fields are mandatory when creating a new instance.
Next, replace // TODO: Add fromJson with:
factory SpoonacularResults.fromJson(Map<String, dynamic> json) =>
_$SpoonacularResultsFromJson(json);
The above method converts the JSON string to a SpoonacularResults object.
Note that the method to the right of the arrow operator doesn’t exist yet and will be present in spoonacular_model.g.dart after generating the code, so ignore any red squiggles. They’ll be created later by running the build_runner command.
Also note that this is a factory method. That’s because you need a class-level method when creating the instance.
Note: To know more about factory methods check Chapter 9 in the Dart Apprentice: Fundamentals Book.
Now, replace // TODO: Add toJson with the following:
Map<String, dynamic> toJson() => _$SpoonacularResultsToJson(this);
The above method connects SpoonacularResultsToJson to the toJson() method.
The _$SpoonacularResultsToJson method will be created for you. This method will return a map and is useful for saving its data.
Then, find // TODO: Add SpoonacularResult, and replace it with the following new class, continuing to ignore the red squiggles:
// 1
@JsonSerializable()
class SpoonacularResult {
// 2
int id;
String title;
String image;
String imageType;
// 3
SpoonacularResult({
required this.id,
required this.title,
required this.image,
required this.imageType,
});
// 4
factory SpoonacularResult.fromJson(Map<String, dynamic> json) =>
_$SpoonacularResultFromJson(json);
Map<String, dynamic> toJson() => _$SpoonacularResultToJson(this);
}
Here’s what this code does:
- Marks the class
JsonSerializable. - Defines several fields:
id,title,imageandimageType. - Defines a
constructorthat accepts these fields. - Adds the methods for JSON serialization.
Now, uncomment the rest of the code in lib/network/spoonacular_model.dart. This will add two new classes, SpoonacularRecipe and ExtendedIngredient plus some conversion methods. This is to save time, as the detailed recipe information from Spoonacular is quite extensive.
For your next step, you’ll generate the code to automatically parse the recipes’ JSON.
Generating the code for JSON Serialization and Deserialization
Open the terminal in Android Studio by clicking the Terminal panel in the lower left, or by selecting View ▸ Tool Windows ▸ Terminal, and type:
dart run build_runner build
The expected output will look something like this:
[INFO] Generating build script completed, took 155ms
[INFO] Precompiling build script... completed, took 3.3s
[INFO] Building new asset graph completed, took 372ms
[INFO] Checking for unexpected pre-existing outputs. completed, took 15.0s
[INFO] Generating SDK summary completed, took 2.3s
[INFO] Running build completed, took 11.8s
[INFO] Caching finalized dependency graph completed, took 44ms
[INFO] Succeeded after 11.8s with 11 outputs (82 actions)
➜
Note: If you have problems running the command, ensure you’ve installed Flutter on your computer and you have a path set up to point to it. See Flutter installation documentation for more details, https://docs.flutter.dev/get-started/install.
You may encounter a problem that looks like this:
[INFO] Found 6 declared outputs which already exist on disk. This is likely because the`.dart_tool/build` folder was deleted, or you are submitting generated files to your source repository.
Delete these files?
1 - Delete
2 - Cancel build
3 - List conflicts
1
Choose 1 to delete the files.
This command creates the spoonacular_model.g.dart file, which has all the generated code in the network folder. If you don’t see the file, right-click on the network folder and choose Reload from disk.
If you still don’t see it, restart Android Studio, so it recognizes the presence of the newly generated file when it starts up.
If you want the program to run every time you make a change to your file, you can use the watch command like this:
dart run build_runner watch
The command will continue to run and watch for changes to files. To stop the process, you can press Ctrl-C. Now, open spoonacular_model.g.dart. Here’s the first generated method:
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'spoonacular_model.dart';
// 1
SpoonacularResults _$SpoonacularResultsFromJson(Map<String, dynamic> json) =>
SpoonacularResults(
// 2
results: (json['results'] as List<dynamic>)
.map((e) => SpoonacularResult.fromJson(e as Map<String, dynamic>))
.toList(),
// 3
offset: json['offset'] as int,
// 4
number: json['number'] as int,
// 5
totalResults: json['totalResults'] as int,
);
Notice that it takes a map of <String, dynamic>, which is typical of JSON data in Flutter. The key is the string, and the value will either be a primitive, a list or another map. The method:
- Returns a new
SpoonacularResultsclass. - Maps each element of the
resultslist to an instance ofSpoonacularResult. - Maps the
offsetkey to aoffsetfield. - Maps the
numberinteger to thenumberfield. - Maps the
totalResultsinteger to thetotalResultsfield.
You could’ve written this code yourself, but it can get a bit tedious and is error-prone. Having a tool generate the code for you saves a lot of time and effort. Look through the rest of the file to see how the generated code converts the JSON data to all the other model classes.
Hot restart the app to make sure it still compiles and works as before. You won’t see any changes in the UI, but the code is now set up to parse recipe data.
Testing the Generated JSON Code
Now that you can parse model objects from JSON, you’ll read one of the JSON files included in the starter project and show one card to make sure you can use the generated code.
Open ui/recipes/recipe_list.dart and add the following imports at the top:
import 'dart:convert';
import '../../network/spoonacular_model.dart';
import 'package:flutter/services.dart';
In fetchData(), replace // TODO: Load Recipes with:
// 1
final jsonString = await rootBundle.loadString('assets/recipes1.json');
// 2
final spoonacularResults =
SpoonacularResults.fromJson(jsonDecode(jsonString));
// 3
final recipes = spoonacularResultsToRecipe(spoonacularResults);
// 4
final apiQueryResults = QueryResult(
offset: spoonacularResults.offset,
number: spoonacularResults.number,
totalResults: spoonacularResults.totalResults,
recipes: recipes);
// 5
currentResponse = Future.value(Success(apiQueryResults));
This is what that code does:
-
rootBundleis from the services page and allows you to load data from the assets directory. -
Decode the JSON string and convert it to a
SpoonacularResultsclass. - Convert that result into a list of recipes.
- Create a new query result that contains the results. This is the class that will be used in Chapter 12, “Networking in Flutter”.
- Return a
Successresponse.
Perform a hot reload, run a search and the app will show some chicken recipe cards:
Mock Service
Now that you’ve manually loaded a sample JSON file, it’s time to implement the Mock Service. This service class will randomly load one of two recipe files: One for chicken and one for pasta.
While in recipe_list.dart, comment out the code you just entered and uncomment this code:
final recipeService = ref.watch(serviceProvider);
currentResponse = recipeService.queryRecipes(
searchTextController.text.trim(), currentStartPosition, pageCount);
Open mock_service/mock_service.dart and add the following imports:
import 'dart:convert';
import 'package:flutter/services.dart';
import '../network/spoonacular_model.dart';
Uncomment the code in loadRecipes() This will randomly load recipes either from recipes1.json or recipes2.json in the assets folder. Next, open main.dart and add the following import:
import 'mock_service/mock_service.dart';
Then replace // TODO: Create Mock service with:
final service = await MockService.create();
Finally replace // TODO: Inject mock service with:
serviceProvider.overrideWithValue(service),
This will inject this service via the Riverpod library. You’ll read more about that library in Chapter 13, “Managing State”. Do a hot restart not reload. Type anything in the search field and press enter, or click the search icon. You should see a list of chicken or pasta recipes.
Now that the data model classes work as expected, you’re ready to load recipes from the web. Fasten your seat belt. :]
Key Points
- JSON is an open-standard format used on the web and in mobile clients, especially with REST APIs.
- In mobile apps, JSON code is usually parsed into the model objects that your app will work with.
- You can write JSON parsing code yourself, but it’s usually easier to let a JSON package generate the parsing code for you.
- json_annotation and json_serializable are packages that will let you generate the parsing code.
Where to Go From Here?
In this chapter, you’ve learned how to create models that you can parse from JSON and then use when you fetch JSON data from the network. If you want to learn more about json_serializable, go to https://pub.dev/packages/json_serializable.
In the next chapter, you’ll build on what you’ve done so far and learn about loading recipes from the internet.