jsonswiftswift-dictionaryswift-structs

How to properly decode nested JSON objects with Swift structs


Intent:

Receive cryptocurrency price data via Coinmarketcap API, decode it into custom structs in SWIFT and potentially store that data in a database (either CoreData or SQLite).

Context:

I am receiving the following error on JSONDecoder().decode:

Error serializing json: typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "status", intValue: nil), _DictionaryCodingKey(stringValue: "credit_count", intValue: nil)], debugDescription: "Expected to decode Dictionary<String, Any> but found a number instead.", underlyingError: nil))

Questions:

  1. How to properly interpret that error? What am I decoding wrong?
  2. Is the data I am receiving correctly formatted? Doesn't look like proper JSON.

The code:

import UIKit
import PlaygroundSupport


// Defining structures

struct RootObject: Decodable {
    let status: [String: StatusObject?]
    let data: DataObject?
}

struct StatusObject: Decodable {
    let credit_count: Int?
    let elapsed: Int?
    let error_code: Int?
    let timestamp: String?
}

struct DataObject: Decodable {
    let amount: Int?
    let id: Int?
    let last_updated: String?
    let name: String?
    let quote: [QuoteObject]?
    let symbol: String?
}

struct QuoteObject: Decodable {
    let usd: String?
}

struct usdObject: Decodable {
    let last_updated: String?
    let price: String?
}


//Configuring URLSession

let config = URLSessionConfiguration.default

config.httpAdditionalHeaders = ["X-CMC_PRO_API_KEY": "<removed>",
                                "Accept": "application/json",
                                "Accept-Encoding": "deflate, gzip"]

let session = URLSession(configuration: config)

let url = URL(string: "https://sandbox-api.coinmarketcap.com/v1/tools/price-conversion?convert=USD&amount=1&symbol=BTC")!


//Making and handling a request

let task = session.dataTask(with: url) { data, response, error in

    guard error == nil else {
        print ("error: \(error!)")
        return
    }

    guard let content = data else {
        print("No data")
        return
    }

    //Serializing and displaying the received data
    guard let json = (try? JSONSerialization.jsonObject(with: content, options: JSONSerialization.ReadingOptions.mutableContainers)) as? [String: Any]
    else {
        print("Not containing JSON")
        return
    }

    print(json)

    //Trying to decode
    do {
        let prices = try JSONDecoder().decode(RootObject.self, from: data!)
        print(prices)
    } catch let decodeError {
        print("Error serializing json:", decodeError)
    }
}

task.resume()

The data response and the error:

["status": {
    "credit_count" = 1;
    elapsed = 6;
    "error_code" = 0;
    "error_message" = "<null>";
    timestamp = "2019-02-16T11:10:22.147Z";
}, "data": {
    amount = 1;
    id = 1;
    "last_updated" = "2018-12-22T06:08:23.000Z";
    name = Bitcoin;
    quote =     {
        USD =         {
            "last_updated" = "2018-12-22T06:08:23.000Z";
            price = "3881.88864625";
        };
    };
    symbol = BTC;
}]
Error serializing json: typeMismatch(Swift.Dictionary<Swift.String, Any>, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "status", intValue: nil), _DictionaryCodingKey(stringValue: "credit_count", intValue: nil)], debugDescription: "Expected to decode Dictionary<String, Any> but found a number instead.", underlyingError: nil))

Edit 1:

Properly serialized JSON:

{
    "status": {
        "timestamp": "2019-02-16T18:54:05.499Z",
        "error_code": 0,
        "error_message": null,
        "elapsed": 6,
        "credit_count": 1
    },
    "data": {
        "id": 1,
        "symbol": "BTC",
        "name": "Bitcoin",
        "amount": 1,
        "last_updated": "2018-12-22T06:08:23.000Z",
        "quote": {
            "USD": {
                "price": 3881.88864625,
                "last_updated": "2018-12-22T06:08:23.000Z"
            }
        }
    }
}

Solution

  • There are a lot of issues in the structs.

    The main issue is that the value for data is a dictionary which is decoded into a struct rather than into another dictionary. Other issues are that the type of id is String and price is Double.

    APIs like Coinmarketcap send reliable data so don't declare everything as optional. Remove the question marks.

    The structs below are able to decode the JSON. The quotes are decoded into a dictionary because the keys change. Add the .convertFromSnakeCase key decoding strategy to get camelCased keys. The dates are decoded as Date by adding an appropriate date decoding strategy.

    I removed all those redundant ...Object occurrences except DataObject because the Data struct already exists.

    struct Root: Decodable {
        let status: Status
        let data: DataObject
    }
    
    struct Status: Decodable {
        let creditCount: Int
        let elapsed: Int
        let errorCode: Int
        let timestamp: Date
    }
    
    struct DataObject: Decodable {
        let amount: Int
        let id: String
        let lastUpdated: Date
        let name: String
        let quote: [String:Quote]
        let symbol: String
    }
    
    struct Quote: Decodable {
        let lastUpdated: Date
        let price: Double
    }
    
    
    //Trying to decode
    do {
        let dateFormatter = DateFormatter()
        dateFormatter.locale = Locale(identifier: "en_US_POSIX")
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .formatted(dateFormatter)
        let result = try decoder.decode(Root.self, from: data!)
        let quotes = result.data.quote
        for (symbol, quote) in quotes {
            print(symbol, quote.price)
        }
    } catch {
        print(error)
    }