Working With the JSON You Get

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.
Using a Data Transfer Object also makes it easier to respond to changes in how the server provides the JSON data. For example, if your backend switches to a v2 JSON format, you can easily adapt by changing the DTO.

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 the strIngredient/strMeasurement keys with values and use those to create an array of Ingredient 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 being Decodable, because the application only consumes this data. If you were able to add your own recipes, then you would need to specify Codable conformance, and you'd need a function to convert the RecipeDetail struct to a RecipeDetailDTO with a corresponding RecipesDTO.

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 the RecipeDetailDTO to RecipeDetail 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 and measure properties are optional. This was necessary because of the occasional values that were null 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.
💡
A followup article is available that describes how to write Unit Test Code for the 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.
sanguish (@sanguish@iosdev.space)
5.89K Posts, 156 Following, 998 Followers · Searching for a macOS or iOS engineering position Ex-Apple DevPubs (12+yrs, remote). I’d go back. macOS/iOS engineer, & author. OCD-sufferer. Like any rational adult, I dislike mimes.

Reach out on Mastodon