16

I'm going through some projects and removing JSON parsing frameworks, as it seems pretty simple to do with Swift 4. I've encountered this oddball JSON return where Ints and Dates are returned as Strings.

I looked at GrokSwift's Parsing JSON with Swift 4, Apple's website, but I don't see anything that jumps out re: changing types.

Apple's example code shows how to change key names, but I'm having a hard time figuring out how to change the key type.

Here's what it looks like:

{
    "WaitTimes": [
        {
            "CheckpointIndex": "1",
            "WaitTime": "1",
            "Created_Datetime": "10/17/2017 6:57:29 PM"
        },
        {
            "CheckpointIndex": "2",
            "WaitTime": "6",
            "Created_Datetime": "10/12/2017 12:28:47 PM"
        },
        {
            "CheckpointIndex": "0",
            "WaitTime": "8",
            "Created_Datetime": "9/26/2017 5:04:42 AM"
        }
    ]
}

I've used CodingKey to rename dictionary keys to a Swift-conforming entry, as follows:

struct WaitTimeContainer: Codable {
  let waitTimes: [WaitTime]

  private enum CodingKeys: String, CodingKey {
    case waitTimes = "WaitTimes"
  }

  struct WaitTime: Codable {
    let checkpointIndex: String
    let waitTime: String
    let createdDateTime: String

    private enum CodingKeys: String, CodingKey {
      case checkpointIndex = "CheckpointIndex"
      case waitTime = "WaitTime"
      case createdDateTime = "Created_Datetime"
    }
  }
}

That still leaves me with String that should be Int or Date. How would I go about converting a JSON return that contains an Int/Date/Float as a String to an Int/Date/Float using the Codable protocol?

Adrian
  • 16,233
  • 18
  • 112
  • 180
  • 1
    @Adrian make sure Created_Datetime when stored to the server it is UTC time and not local time otherwise you should't set the date formatter timezone to zero secondsFromGMT when parsing your dates. – Leo Dabus Oct 24 '17 at 04:37

3 Answers3

16

This is not yet possible as Swift team has provided only String to date decoder in JSONDecoder.

You can always decode manually though:

struct WaitTimeContainer: Decodable {
    let waitTimes: [WaitTime]

    private enum CodingKeys: String, CodingKey {
        case waitTimes = "WaitTimes"
    }

    struct WaitTime:Decodable {
        let checkpointIndex: Int
        let waitTime: Float
        let createdDateTime: Date

        init(checkpointIndex: Int, waitTime: Float, createdDateTime:Date) {
            self.checkpointIndex = checkpointIndex
            self.waitTime = waitTime
            self.createdDateTime = createdDateTime
        }

        static let formatter: DateFormatter = {
            let formatter = DateFormatter()
            formatter.calendar = Calendar(identifier: .iso8601)
            formatter.locale = Locale(identifier: "en_US_POSIX")
            formatter.timeZone = TimeZone(secondsFromGMT: 0)
            formatter.dateFormat = "MM/dd/yyyy hh:mm:ss a"
            return formatter
        }()

        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            let checkpointIndexString = try container.decode(String.self, forKey: .checkpointIndex)
            let checkpointIndex = Int(checkpointIndexString)!

            let waitTimeString = try container.decode(String.self, forKey: .waitTime)
            let waitTime = Float(waitTimeString)!

            let createdDateTimeString =  try container.decode(String.self, forKey: .createdDateTime)

            let createdDateTime = WaitTime.formatter.date(from: createdDateTimeString)!

            self.init(checkpointIndex:checkpointIndex, waitTime:waitTime, createdDateTime:createdDateTime)
        }

        private enum CodingKeys: String, CodingKey {
            case checkpointIndex = "CheckpointIndex"
            case waitTime = "WaitTime"
            case createdDateTime = "Created_Datetime"
        }
    }
}
Puneet Sharma
  • 9,369
  • 1
  • 27
  • 33
0
public extension KeyedDecodingContainer {
public func decode(_ type: Date.Type, forKey key: Key) throws -> Date {
    let dateString = try self.decode(String.self, forKey: key)
    let dateFormatter = DateFormatter()
    dateFormatter.dateFormat = "MM/dd/yyyy hh:mm:ss a"
    guard let date = dateFormatter.date(from: dateString) else {
        let context = DecodingError.Context(codingPath: codingPath,
                                            debugDescription: "Could not parse json key to a Date")
        throw DecodingError.dataCorrupted(context)
    }
    return date
}
}

Usage: -

let date: Date = try container.decode(Date.self, forKey: . createdDateTime)
Ichigo Kurosaki
  • 3,765
  • 8
  • 41
  • 56
Deep Parekh
  • 445
  • 5
  • 2
  • 2
    Just a few notes: you should extend Formatter and create a static DateFormatter out of your method. Also you need to set its locale to "en_US_POSIX" otherwise it might fail due to an user device time format set to 24 hours instead of 12. – Leo Dabus Oct 24 '17 at 04:25
  • 1
    If you really want to declare your date formatter inside your method you should also provide another parameter allowing the developer to pass a custom dateFormat. Btw you could also change the name to decodeDate and remove the first parameter type: Date.Type which is not needed. – Leo Dabus Oct 24 '17 at 04:26
  • 1
    You could also include in your debugDescription using string interpolation, the key and value that failed to be decoded. – Leo Dabus Oct 24 '17 at 04:29
  • 1
    `extension Formatter { static let custom: DateFormatter = { let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.dateFormat = "MM/dd/yyyy hh:mm:ss a" return formatter }() }` – Leo Dabus Oct 24 '17 at 04:29
  • 1
    `extension KeyedDecodingContainer { func decodeCustomDate(forKey key: Key) throws -> Date { let string = try decode(String.self, forKey: key) guard let date = Formatter.custom.date(from: string) else { let context = DecodingError.Context( codingPath: codingPath, debugDescription: "Could not parse json key: \(key), value: \(string) into a Date" ) throw DecodingError.dataCorrupted(context) } return date } }` – Leo Dabus Oct 24 '17 at 04:29
0

Let me give suggest two approaches: one for dealing with String backed values and another - for dealing with dates that might come in different formats. Hope the example is self-explantory.

import Foundation

protocol StringRepresentable: CustomStringConvertible {
    init?(_ string: String)
}

extension Int: StringRepresentable {}
extension Double: StringRepresentable {}

struct StringBacked<Value: StringRepresentable>: Codable, CustomStringConvertible {
    var value: Value
    
    var description: String {
        value.description
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let string = try container.decode(String.self)
        
        guard let value = Value(string) else {
            throw DecodingError.dataCorruptedError(
                in: container,
                debugDescription: """
                Failed to convert an instance of \(Value.self) from "\(string)"
                """
            )
        }
        
        self.value = value
    }
    
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(value.description)
    }
}

let decoder = JSONDecoder()

decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
    let container = try decoder.singleValueContainer()
    let dateStr = try container.decode(String.self)
    
    let formatters = [
        "yyyy-MM-dd",
        "yyyy-MM-dd'T'HH:mm:ssZZZZZ",
        "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ",
        "yyyy-MM-dd'T'HH:mm:ss'Z'",
        "yyyy-MM-dd'T'HH:mm:ss.SSS",
        "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
        "yyyy-MM-dd HH:mm:ss",
        "MM/dd/yyyy HH:mm:ss",
        "MM/dd/yyyy hh:mm:ss a"
    ].map { (format: String) -> DateFormatter in
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = format
        return formatter
    }
    
    for formatter in formatters {
        
        if let date = formatter.date(from: dateStr) {
            return date
        }
    }
    
    throw DecodingError.valueNotFound(String.self, DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not parse json key: \(container.codingPath), value: \(dateStr) into a Date"))
})

// Test it with data:

let jsonData = """
{
    "WaitTimes": [
        {
            "CheckpointIndex": "1",
            "WaitTime": "1",
            "Created_Datetime": "10/17/2017 6:57:29 PM"
        },
        {
            "CheckpointIndex": "2",
            "WaitTime": "6",
            "Created_Datetime": "10/12/2017 12:28:47 PM"
        },
        {
            "CheckpointIndex": "0",
            "WaitTime": "8",
            "Created_Datetime": "9/26/2017 5:04:42 AM"
        }
    ]
}
""".data(using: .utf8)!

struct WaitTimeContainer: Codable {
    let waitTimes: [WaitTime]
    
    private enum CodingKeys: String, CodingKey {
        case waitTimes = "WaitTimes"
    }
    
    struct WaitTime: Codable {
        
        var checkpointIndex: Int {
            get { return checkpointIndexString.value }
            set { checkpointIndexString.value = newValue }
        }
        
        var waitTime: Double {
            get { return waitTimeString.value }
            set { waitTimeString.value = newValue }
        }
        
        let createdDateTime: Date
        
        private var checkpointIndexString: StringBacked<Int>
        private var waitTimeString: StringBacked<Double>
        
        private enum CodingKeys: String, CodingKey {
            case checkpointIndexString = "CheckpointIndex"
            case waitTimeString = "WaitTime"
            case createdDateTime = "Created_Datetime"
        }
    }
}

let waitTimeContainer = try decoder.decode(WaitTimeContainer.self, from: jsonData)
print(waitTimeContainer)
Paul B
  • 3,989
  • 33
  • 46