The code for this project is available on GitHub.
Often, the JSON that a server provides isn't in the format you really want to use as your model. Assuming that our parsing is going to be done using Swift's Codable
protocol, you have several options:
- Live with a horrible model object.
- Write your own
init(from decoder:)
as Apple demonstrates in the Encode and Decode Manually section of Encoding and Decoding Custom Types. - Create an intermediate model that meshes well with the JSON you're given, and then convert that model object to a new class that's suitable for your application. That intermediate model is called a Data Transfer Object, or DTO for short.
I was given the task of retrieving and parsing JSON from themealdb.com. It's a free, open-source, recipe database that provides an API to retrieve meal categories, recipes, and full recipe details. The JSON format for most of the API calls is fairly simple and the JSON returned by some endpoints can be parsed into well designed model objects using the automatic implementation of Codable
, by providing codable keys to translate the JSON key names to more appropriate property names.
Unfortunately, the endpoint to get the full recipe details provides data in an inconvenient format. And it's certainly not in a form that can be directly used as a data model.
Dissecting the Recipe Details
With the data that is available from the endpoint, our ideal RecipeDetail
model would look something like this:
struct Ingredient: Identifiable {
var id: UUID = UUID()
var name: String
var measure: String
}
struct RecipeDetail: Identifiable {
var id: String
var name: String
var category: String
var instructions: String
var mealThumbnailURL: URL?
var keywords: [String]
var ingredients: [Ingredient]
}
The property names all make sense, and the ingredients are an array of Ingredient
structs with the ingredient name and the measurement. I've removed most of the fields from the RecipeDetail struct for clarity.
However, the endpoint gives us this JSON:
{
"meals": [
{
"idMeal": "52958",
"strMeal": "Peanut Butter Cookies",
"strDrinkAlternate": null,
"strCategory": "Dessert",
"strArea": "American",
"strInstructions": "Preheat oven to 350\u00baF (180\u00baC).\r\nIn a large bowl, mix together the peanut butter, sugar, and egg.\r\nScoop out a spoonful of dough and roll it into a ball. Place the cookie balls onto a nonstick baking sheet.\r\nFor extra decoration and to make them cook more evenly, flatten the cookie balls by pressing a fork down on top of them, then press it down again at a 90\u00ba angle to make a criss-cross pattern.\r\nBake for 8-10 minutes or until the bottom of the cookies are golden brown.\r\nRemove from baking sheet and cool.\r\nEnjoy!",
"strMealThumb": "https:\/\/www.themealdb.com\/images\/media\/meals\/1544384070.jpg",
"strTags": "Breakfast,UnHealthy,BBQ",
"strYoutube": "",
"strIngredient1": "Peanut Butter",
"strIngredient2": "Sugar",
"strIngredient3": "Egg",
"strIngredient4": "",
"strIngredient5": "",
"strIngredient6": "",
"strIngredient7": "",
"strIngredient8": "",
"strIngredient9": "",
"strIngredient10": "",
"strIngredient11": "",
"strIngredient12": "",
"strIngredient13": "",
"strIngredient14": "",
"strIngredient15": "",
"strIngredient16": "",
"strIngredient17": "",
"strIngredient18": "",
"strIngredient19": "",
"strIngredient20": "",
"strMeasure1": "1 cup ",
"strMeasure2": "1\/2 cup ",
"strMeasure3": "1",
"strMeasure4": "",
"strMeasure5": "",
"strMeasure6": "",
"strMeasure7": "",
"strMeasure8": "",
"strMeasure9": "",
"strMeasure10": "",
"strMeasure11": "",
"strMeasure12": "",
"strMeasure13": "",
"strMeasure14": "",
"strMeasure15": "",
"strMeasure16": "",
"strMeasure17": "",
"strMeasure18": "",
"strMeasure19": "",
"strMeasure20": "",
"strSource": "https:\/\/tasty.co\/recipe\/3-ingredient-peanut-butter-cookies",
"strImageSource": null,
"strCreativeCommonsConfirmed": null,
"dateModified": null
}
]
}
There is a lot to parse here and there is zero documentation on the API page.
- We get an array of meals back, although the endpoint only allows asking for a single recipe ID. So we have another level to decode and only ever use the first and only item in the
meals
array. - The JSON key names aren't particularly well named for our model, but that's easily fixed using a
CodingKeys
enum. - The ingredients and measurements are all given as individual keys, and there are a maximum of 20 pairs. Every key is present in the JSON, even if it's empty. And while they're all showing some value in this particular record, I found that there are cases where a value can randomly be
null
for some recipes. So we need to collect only thestrIngredient
/strMeasurement
keys with values and use those to create an array ofIngredient
structs. This is the primary driver for needing a DTO approach. - The keywords are given as a single string with comma separated values. We want them as an array of
String
instances.
Introducing RecipesDTO
and RecipeDetailDTO
To decode this JSON we'll create two DTOs. The RecipesDTO
is a straightforward struct that uses the automatic Decodable
implementation to decode the array.
struct RecipesDTO: Decodable {
private let meals: [RecipeDetailDTO]
enum CodingKeys: String, CodingKey {
case meals
}
}
I've only specified conformance as beingDecodable
, because the application only consumes this data. If you were able to add your own recipes, then you would need to specifyCodable
conformance, and you'd need a function to convert theRecipeDetail
struct to aRecipeDetailDTO
with a correspondingRecipesDTO
.
The RecipeDetailDTO
has a lot more properties.
struct RecipeDetailDTO: Decodable {
fileprivate let id: String
fileprivate let name: String
fileprivate let category: String
fileprivate let instructions: String
fileprivate let mealThumbnailURL: URL?
fileprivate let keywords: String?
fileprivate let ingredient1: String?
fileprivate let ingredient2: String?
fileprivate let ingredient3: String?
fileprivate let ingredient4: String?
fileprivate let ingredient5: String?
fileprivate let ingredient6: String?
fileprivate let ingredient7: String?
fileprivate let ingredient8: String?
fileprivate let ingredient9: String?
fileprivate let ingredient10: String?
fileprivate let ingredient11: String?
fileprivate let ingredient12: String?
fileprivate let ingredient13: String?
fileprivate let ingredient14: String?
fileprivate let ingredient15: String?
fileprivate let ingredient16: String?
fileprivate let ingredient17: String?
fileprivate let ingredient18: String?
fileprivate let ingredient19: String?
fileprivate let ingredient20: String?
fileprivate let measure1: String?
fileprivate let measure2: String?
fileprivate let measure3: String?
fileprivate let measure4: String?
fileprivate let measure5: String?
fileprivate let measure6: String?
fileprivate let measure7: String?
fileprivate let measure8: String?
fileprivate let measure9: String?
fileprivate let measure10: String?
fileprivate let measure11: String?
fileprivate let measure12: String?
fileprivate let measure13: String?
fileprivate let measure14: String?
fileprivate let measure15: String?
fileprivate let measure16: String?
fileprivate let measure17: String?
fileprivate let measure18: String?
fileprivate let measure19: String?
fileprivate let measure20: String?
Some things to note:
- All the properties are
fileprivate
. The conversion from theRecipeDetailDTO
toRecipeDetail
is done in an extension in this file, so I've isolated them as much as possible. - I've made the
mealThumbnailURL
property optional. I don't trust that the URL will be present, even though it should be. - All the
ingredient
andmeasure
properties are optional. This was necessary because of the occasional values that werenull
instead of just an empty string.
The CodingKeys
enum
renames all these keys to the clearer property names. This isn't strictly necessary since the DTO isn't interacted with after the conversion, but it bothered me.
enum CodingKeys: String, CodingKey {
case id = "idMeal"
case name = "strMeal"
case category = "strCategory"
case instructions = "strInstructions"
case mealThumbnailURL = "strMealThumb"
case keywords = "strTags"
case ingredient1 = "strIngredient1"
case ingredient2 = "strIngredient2"
case ingredient3 = "strIngredient3"
case ingredient4 = "strIngredient4"
case ingredient5 = "strIngredient5"
case ingredient6 = "strIngredient6"
case ingredient7 = "strIngredient7"
case ingredient8 = "strIngredient8"
case ingredient9 = "strIngredient9"
case ingredient10 = "strIngredient10"
case ingredient11 = "strIngredient11"
case ingredient12 = "strIngredient12"
case ingredient13 = "strIngredient13"
case ingredient14 = "strIngredient14"
case ingredient15 = "strIngredient15"
case ingredient16 = "strIngredient16"
case ingredient17 = "strIngredient17"
case ingredient18 = "strIngredient18"
case ingredient19 = "strIngredient19"
case ingredient20 = "strIngredient20"
case measure1 = "strMeasure1"
case measure2 = "strMeasure2"
case measure3 = "strMeasure3"
case measure4 = "strMeasure4"
case measure5 = "strMeasure5"
case measure6 = "strMeasure6"
case measure7 = "strMeasure7"
case measure8 = "strMeasure8"
case measure9 = "strMeasure9"
case measure10 = "strMeasure10"
case measure11 = "strMeasure11"
case measure12 = "strMeasure12"
case measure13 = "strMeasure13"
case measure14 = "strMeasure14"
case measure15 = "strMeasure15"
case measure16 = "strMeasure16"
case measure17 = "strMeasure17"
case measure18 = "strMeasure18"
case measure19 = "strMeasure19"
case measure20 = "strMeasure20"
}
Those two listings make up the entirety of the RecipesDetailDTO
struct. We rely on the automatic Decodable
implementation to parse the JSON into the model, and that works fine.
From RecipeDetailDTO
to RecipeDetail
To convert from the DTO to the desired model object I've created an extension on RecipeDetail
that resides in the RecipeDetailDTO
file (to isolate the DTO pattern as much as possible) and implements init(fromDTO dto:)
which instantiates a clean version of the RecipeDetail
struct.
init(fromDTO dto: RecipeDetailDTO) {
var ingredients: [Ingredient] = []
func appendIngredient(name: String?, measure: String?) {
if let name,
let measure,
name != "" {
let ingredient = Ingredient(name: name,
measure: measure)
ingredients.append(ingredient)
}
}
appendIngredient(name: dto.ingredient1, measure: dto.measure1)
appendIngredient(name: dto.ingredient2, measure: dto.measure2)
appendIngredient(name: dto.ingredient3, measure: dto.measure3)
appendIngredient(name: dto.ingredient4, measure: dto.measure4)
appendIngredient(name: dto.ingredient5, measure: dto.measure5)
appendIngredient(name: dto.ingredient6, measure: dto.measure6)
appendIngredient(name: dto.ingredient7, measure: dto.measure7)
appendIngredient(name: dto.ingredient8, measure: dto.measure8)
appendIngredient(name: dto.ingredient9, measure: dto.measure9)
appendIngredient(name: dto.ingredient10, measure: dto.measure10)
appendIngredient(name: dto.ingredient11, measure: dto.measure11)
appendIngredient(name: dto.ingredient12, measure: dto.measure12)
appendIngredient(name: dto.ingredient13, measure: dto.measure13)
appendIngredient(name: dto.ingredient14, measure: dto.measure14)
appendIngredient(name: dto.ingredient15, measure: dto.measure15)
appendIngredient(name: dto.ingredient16, measure: dto.measure16)
appendIngredient(name: dto.ingredient17, measure: dto.measure17)
appendIngredient(name: dto.ingredient18, measure: dto.measure18)
appendIngredient(name: dto.ingredient19, measure: dto.measure19)
appendIngredient(name: dto.ingredient20, measure: dto.measure20)
// Split the keywords into an array for future use.
let keywords = dto.keywords?.split(separator: ",").map {
String($0)
}
self.init(id: dto.id,
name: dto.name,
category: dto.category,
instructions: dto.instructions,
mealThumbnailURL: dto.mealThumbnailURL,
keywords: keywords ?? [],
ingredients: ingredients)
}
}
This code is fairly straightforward, with the only exception of note being the creation of the Ingredient
entries.
- I isolated all the testing to ensure that each ingredient name and measurement are present and not an empty string, to a nested function with a series of cascading
if let
tests. If it ends up creating an Ingredient struct it's added to the Ingredients array, otherwise it does nothing. - The keywords are split up into an array at every comma. I don't particularly trust that there won't be whitespace around them, but I've chose to remain ignorant of that for the sake of brevity.
- This then calls the synthesized init method on the
RecipeDetail
to create the final instance.
You can then load the JSON from a file or the network into a Data
instance and using JSONDecoder
to parse it. I've created a basic ViewModel
in the example app that loads the JSON from a file and displays the recipe name and thumbnail using SwiftUI.
@Observable
class ViewModel {
var recipe: RecipeDetail? = nil
init() {
let filename = Bundle.main.url(forResource: "RecipeDetail",
withExtension: "json")
if let filename {
if let rawJSON = try? Data(contentsOf: filename) {
let recipesDTO = try? JSONDecoder().decode(RecipesDTO.self,
from: rawJSON)
self.recipe = recipesDTO?.recipe
}
}
}
}
Now, of course, a real implementation would catch errors and do something user friendly. But in this case, The journey is the reward.
The code for this project is available on GitHub.
RecipeDetail
and RecipeDetailDTO
classes. See DTO Testing with the New Swift Testing System.I'm looking for a full-time iOS or macOS senior position. Feel free to contact me or check out my Resume.