17

I'm currently trying to learn Swift and haven't gotten very far yet, so forgive me if this is an easy problem; I've been working on it for hours now and haven't been able to figure it out.

I have a Codable class called Person. On this class I have a Date property called birthdate. So it looks like this:

class Person : Codable {
    var birthdate: Date = Date()
    var firstName: String = ""
    var lastName: String = ""

    enum CodingKeys : String, CodingKey {
        case birthdate
        case firstName = "first_name"
        case lastName = "last_name"
    }
}

And I'm trying to decode my JSON:

[
    {
        "address": "302 N. 5th St.",
        "base_64_image": null,
        "birthdate": "2009-05-06T18:56:38.367",
        "created": "2017-11-21T16:21:13",
        "emergency_contact": "",
        "emergency_contact_number": null,
        "father_cell_number": null,
        "father_home_number": null,
        "father_name": null,
        "first_name": "John",
        "gender": 1,
        "id": "d92fac59-66b9-49a5-9446-005babed617a",
        "image_uri": null,
        "is_inactive": false,
        "last_name": "Smith",
        "mother_cell_number": "1234567890",
        "mother_home_number": "",
        "mother_name": "His Mother",
        "nickname": null,
        "tenant_id": "9518352f-4855-4699-b0da-ecdc06470342",
        "updated": "2018-01-20T02:11:45.9025023"
    }
]

like this:

// Fetch the data from the URL.
let headers: HTTPHeaders = [
    "Accept": "application/json"
]

Alamofire.request(url, headers: headers).responseJSON { response in
    if let data = response.data {
        let decoder = JSONDecoder()

        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
        decoder.dateDecodingStrategy = .formatted(dateFormatter)

        let people = try! decoder.decode(Array<Person>.self, from: data)
    }
}

However, I always get the same error:

Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.dataCorrupted(Swift.DecodingError.Context(codingPath: [Foundation.(_JSONKey in _12768CA107A31EF2DCE034FD75B541C9)(stringValue: "Index 47", intValue: Optional(47)), App.Person.CodingKeys.birthdate], debugDescription: "Date string does not match format expected by formatter.", underlyingError: nil))

(The "Index 47" obviously isn't accurate, since that's for my live [and private] data).

If I take the birthdate property off the Person class everything works as expected.

I've been Googling and trying new things for several hours, and still can't get it to work no matter what I try. Can anyone here help me out?

shawnseanshaun
  • 1,071
  • 1
  • 13
  • 25
  • Unrelated, but there are several architectural issues with your code. Never give meaningless default values to instance properties just to get away from having to create an initializer for a class, especially when those properties should be immutable rather than mutable. Moreover, use structs unless you have a good reason to use a class, especially since you get the memberwise initializer synthesised automatically with structs. You also shouldn't be using force try, handle the `try` in a do-catch block. – Dávid Pásztor Mar 18 '18 at 01:07
  • Is your issue resolved if you set the date formatter's `locale` to `Locale(identifier: "en_US_POSIX")`? – rmaddy Mar 18 '18 at 01:09
  • You also have a big problem with timezones. Each user that runs your app will interpret that birthday date string as their own local time. That's not good. Either specific the timezone in the date string or set the date formatter's `timeZone` with the timezone represented by the date string. But it is much better if the string itself includes its own time zone information. – rmaddy Mar 18 '18 at 01:11
  • @DávidPásztor Thanks for the info. So far what I have is just copy-pasted from Stack Overflow, so I'm definitely planning to refactor things as I go and learn more about Swift. – shawnseanshaun Mar 18 '18 at 01:12
  • @rmaddy The dates are UTC. This is a very specific application; all the users are in the same time zone, and there's no chance of that changing. In answer to your first question, setting the locale does not help. – shawnseanshaun Mar 18 '18 at 01:12
  • 1
    *"all the users are in the same time zone, and there's no chance of that changing"* - famous last words. It really is best to avoid such assumptions. It's a trivial change that will prevent unexpected bugs down the road. – rmaddy Mar 18 '18 at 01:16
  • @rmaddy Well that's a change I can make server-side if the need arises (or before, as you suggested). But it's outside the scope of the question, I think, unless it being UTC would make a difference in the formatting? – shawnseanshaun Mar 18 '18 at 01:21

4 Answers4

19

It looks like one of your birthdates:

"birthdate": "2009-05-06T18:56:38.367",

contains milliseconds. Your date format string:

dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"

Isn't able to handle this. You can either change the birthdate field in the incoming JSON, or change your dateFormat string to this:

dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS"

Note that adding .SSS appears to break the formatter for non-millisecond dates. I'd recommend cutting out the milliseconds server-side.


Original answer below:

I've just tried this in a Playground, and it appears to work as expected:

class Person : Codable {
    var birthdate: Date = Date()
    var firstName: String = ""
    var lastName: String = ""

    enum CodingKeys : String, CodingKey {
        case birthdate
        case firstName = "first_name"
        case lastName = "last_name"
    }
}

var json: String = """
[
{
    "birthdate": "2009-05-06T18:56:38",
    "first_name": "John",
    "last_name": "Smith"
}
]
"""

let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
decoder.dateDecodingStrategy = .formatted(dateFormatter)

let people = try! decoder.decode(Array<Person>.self, from: json.data(using: .utf8, allowLossyConversion: false)!)

Where people is now this:

{birthdate "May 6, 2009 at 6:56 PM", firstName "John", lastName "Smith"}

Either there's something subtly different between my code and yours, or there may be a different set of example data needed.

Zundo
  • 266
  • 2
  • 4
  • Just tried the new sample data, with no difference. Same output, no error. – Zundo Mar 18 '18 at 01:19
  • @TastesLikeTurkey Mine is also a `Data` object (from the call to `json.data(...)`). Are you using a certain locale or Swift version on your machine? – Zundo Mar 18 '18 at 01:20
  • @TastesLikeTurkey Just trying to think of differences in setup that could cause this. I'm going to try it out on iOS and see if anything changes. You'd probably know if you were using a special/older version of Swift. – Zundo Mar 18 '18 at 01:24
  • @Zundo FYI - https://meta.stackoverflow.com/questions/277923/are-your-code-works-fine-for-me-answers-acceptable – rmaddy Mar 18 '18 at 01:27
  • No difference running the above code w/ the new sample data on an iOS 11.2 simulator. I'm not sure what's going on here. – Zundo Mar 18 '18 at 01:29
  • That's not a valid reason to post a non-answer, sorry. – rmaddy Mar 18 '18 at 01:32
  • What happens if you log `response.data`, @TastesLikeTurkey? Might get some insight into something subtly different there. – Zundo Mar 18 '18 at 01:33
  • @TastesLikeTurkey That, or even looking at the raw data - is there any information about the encoding, etc? Odd hidden characters in the response? – Zundo Mar 18 '18 at 01:44
  • @TastesLikeTurkey What happens if you ignore the Alamo response data, and instead put known data into a string and call `data(using: .utf8, allowLossyConversion: false)!` on it, as I do in this answer? – Zundo Mar 18 '18 at 01:51
  • @Zundo Huh. It works fine. My best guess is that one of my 176 items has a date with milliseconds (quite probable). One of the methods I'd tried should have dealt with that, but I just now came across [this answer](https://stackoverflow.com/a/48371185/7957152) that says when using `Codable`, the method I used won't work; you need a custom initializer. I'll give that a shot. – shawnseanshaun Mar 18 '18 at 02:01
  • @Zundo Yup, that was the issue. Adding the custom init allowed me to see exactly which date it was failing on. I've changed my sample data to reflect that in the hopes that it can help people in the future. If you want to modify your answer accordingly, I'll mark it as accepted. – shawnseanshaun Mar 18 '18 at 02:24
  • Also, apologies for the wild goose chase due to my supplying the wrong date for testing; that was the date at index 47 in the original string, so I assumed that was the one it was failing on. – shawnseanshaun Mar 18 '18 at 02:33
  • Done @TastesLikeTurkey - glad I was able to help somewhat! – Zundo Mar 19 '18 at 02:44
  • Why does swift overcomplicate everything lol? Thanks for the answer! – JMK Jun 07 '21 at 19:16
4

You just forgot to add milliseconds to your date format.

Change this line:
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss".
With this:
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS"

arturdev
  • 10,884
  • 2
  • 39
  • 67
4

If you decode JSON with dates in multiple parts of your code I recommend doing a custom class to adapt the decoding to what you need in this case: decoding Date.


The implementation will be something like this:

/**Custom decoder for dates*/
class DecoderDates: JSONDecoder {

    override func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
        let decoder = JSONDecoder()
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS"
        decoder.dateDecodingStrategy = .formatted(dateFormatter)
        return try decoder.decode(T.self, from: data)
    }

}

Example use:

DecoderDates().decode(Codable.self, from: data)

Hope this helps someone.

pkamb
  • 33,281
  • 23
  • 160
  • 191
reojased
  • 709
  • 1
  • 7
  • 19
2

Details

  • Xcode Version 12.3 (12C33)
  • Swift 5.3

Solution

import Foundation

protocol StaticDateFormatterInterface {
    static var value: DateFormatter { get }
}

enum DecodableDate<Formatter> where Formatter: StaticDateFormatterInterface {
    case value(Date)
    case error(DecodingError)
    
    var value: Date? {
        switch self {
        case .value(let value): return value
        case .error: return nil
        }
    }
    
    var error: DecodingError? {
        switch self {
        case .value: return nil
        case .error(let error): return error
        }
    }
    
    enum DecodingError: Error {
        case wrongFormat(source: String, dateFormatter: DateFormatter)
        case decoding(error: Error)
    }
}

extension DecodableDate: Decodable {
    func createDateFormatter() -> DateFormatter {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
        return dateFormatter
    }
    
    init(from decoder: Decoder) throws {
        do {
            let dateString = try decoder.singleValueContainer().decode(String.self)
            guard let date = Formatter.value.date(from: dateString) else {
                self = .error(DecodingError.wrongFormat(source: dateString, dateFormatter: Formatter.value))
                return
            }
            self = .value(date)
        } catch let err {
            self = .error(.decoding(error: err))
        }
    }
}

Usage

class DefaultDateFormatter: StaticDateFormatterInterface {
    static var value: DateFormatter = {
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
        return dateFormatter
    }()
}
 
struct Dates: Decodable {
    let date1: DecodableDate<DefaultDateFormatter>
    let date2: DecodableDate<DefaultDateFormatter>
    let date3: DecodableDate<DefaultDateFormatter>
    let date4: DecodableDate<DefaultDateFormatter>
    let date5: DecodableDate<DefaultDateFormatter>
}

var text = [
    "date1": "2020-06-03T01:43:44.888Z",
    "date2": "2020-06-03_01:43:44.888Z",
    "date3": "blabla",
    "date4": ["blabla"],
    "date5": 22,
] as [String: Any]


let data = try! JSONSerialization.data(withJSONObject: text)
let object = try JSONDecoder().decode(Dates.self, from: data)
print(object.date1)
print(object.date2)
print(object.date3)
print(object.date4)
print(object.date5)

func print(_ obj: DecodableDate<DefaultDateFormatter>) {
    switch obj {
    case .error(let error): print("Error: \(error)")
    case .value(let date): print("Value: \(date)")
    }
}

Log

// Value: 2020-06-03 01:43:44 +0000
// Error: wrongFormat(source: "2020-06-03_01:43:44.888Z", dateFormatter: <NSDateFormatter: 0x60000169c150>)
// Error: wrongFormat(source: "blabla", dateFormatter: <NSDateFormatter: 0x60000169c150>)
// Error: decoding(error: Swift.DecodingError.typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "date4", intValue: nil)], debugDescription: "Expected to decode String but found an array instead.", underlyingError: nil)))
// Error: decoding(error: Swift.DecodingError.typeMismatch(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "date5", intValue: nil)], debugDescription: "Expected to decode String but found a number instead.", underlyingError: nil)))
Vasily Bodnarchuk
  • 24,482
  • 9
  • 132
  • 127