Search code examples
jsonswiftstringcodabledecodable

Swift Decodable Dates with Empty Strings or Nil


Situation

I’m dealing with an API server that has multiple Date fields and I have seen that the API response for these Date fields can be:

{
 "clicktimestamp": "",
 "clicktimestamp": "  ",  
 "clicktimestamp": "2020-08-08 16:13:17"
}

The JSON response could be:
• String (no white space)
• String (with whitespace)
• String with some Date format.

I don’t have access to the API server and I can’t ask the server side engineer to change it. My situation is not ideal so I have to deal with it.

Working Solution (Not Very Swifty)

I wrote some code to deal with this situation. It works but something about it does not feel very Swift.

How can I improve this whole decoding process considering my JSON response situation?
Or is it good?

Here is a working solution:

import UIKit
import Foundation

struct ProductDate: Decodable, Hashable {
    var lastcheckedtime: Date?
    var oktime: Date?
    var clicktimestamp: Date?
    var lastlocaltime: Date?
    // I have more properties but I'm omitting them
}

extension ProductDate {
    
    private enum Keys: String, CodingKey {
        case lastcheckedtime
        case oktime
        case clicktimestamp
        case lastlocaltime
    }
    
    init(from decoder: Decoder) throws {
        
        let formatter = DateFormatter.yyyyMMdd
        let container = try decoder.container(keyedBy: Keys.self)
    
        let dateKeys: [KeyedDecodingContainer<Keys>.Key] = [
            .lastcheckedtime,
            .oktime,
            .clicktimestamp,
            .lastlocaltime
        ]

        let parseDate: (String, KeyedDecodingContainer<Keys>.Key, KeyedDecodingContainer<Keys>) throws -> Date? = {(dateString, someKey, container) in
             if !dateString.isEmpty {
                 if let date = formatter.date(from: dateString) {
                     return date
                 } else {
                     throw DecodingError.dataCorruptedError(forKey: someKey,
                                     in: container,
                                     debugDescription: "Date string does not match format expected by formatter.")
                 }
             } else {
                 return nil
             }
         }

        let datesResults: [Date?] = try dateKeys.map({ key in
            // 1.  decode as a string because we sometimes get "" or  " " for those date fields as the API server is poorly managed.
            let dateString = try container.decode(String.self, forKey: key)
                                            .trimmingCharacters(in: .whitespaces)
            // 2. now pass in our dateString which could be "" or " " or "2020-08-08 16:13:17"
            // and try to parse it into a Date or nil
            let result = try parseDate(dateString, key, container)
            return result
        })

        // 3. Assign our array of dateResults to our struct keys
        lastcheckedtime = datesResults[0]
        oktime          = datesResults[1]
        clicktimestamp  = datesResults[2]
        lastlocaltime   = datesResults[3]
        
        
    }
    
}


extension DateFormatter {
  static let yyyyMMdd: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "YYYY-MM-DD HH:mm:ss"
    formatter.calendar = Calendar(identifier: .iso8601)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.locale = Locale(identifier: "en_US_POSIX")
    return formatter
  }()
}


let json = """
{
   "lastcheckedtime": "",
   "oktime": " ",
   "clicktimestamp": "",
   "lastlocaltime": "2020-08-08 16:13:17"
}
""".data(using: .utf8)!


let decoder = JSONDecoder()


print(json)

do {
    let decoded = try decoder.decode(ProductDate.self, from: json)
    print(decoded)
} catch let context {
   print(context)
}



Solution

  • Too complicated.

    Add a dateDecodingStrategy and decode Date. If it fails assign nil.

    No trimming stuff needed.

    Note also that your date format is wrong.

    struct ProductDate: Decodable, Hashable {
        var lastcheckedtime: Date?
        var oktime: Date?
        var clicktimestamp: Date?
        var lastlocaltime: Date?
    }
    
    extension ProductDate {
        
        private enum CodingKeys: String, CodingKey {
            case lastcheckedtime, oktime, clicktimestamp, lastlocaltime
        }
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            lastcheckedtime = try? container.decode(Date.self, forKey: .lastcheckedtime)
            oktime = try? container.decode(Date.self, forKey: .oktime)
            clicktimestamp = try? container.decode(Date.self, forKey: .clicktimestamp)
            lastlocaltime = try? container.decode(Date.self, forKey: .lastlocaltime)
        }
    }
    
    
    extension DateFormatter {
      static let yyyyMMdd: DateFormatter = {
        let formatter = DateFormatter()
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        return formatter
      }()
    }
    
    
    let json = """
    {
       "lastcheckedtime": "",
       "oktime": " ",
       "clicktimestamp": "",
       "lastlocaltime": "2020-08-08 16:13:17"
    }
    """
    
    
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .formatted(.yyyyMMdd)
    
    
    print(json)
    
    do {
        let decoded = try decoder.decode(ProductDate.self, from: Data(json.utf8))
        print(decoded)
    } catch let context {
       print(context)
    }