问题
I am getting the following JSON back from a network request:
{
"uvIndex": 5
}
I use the following type to decode the string-to-data result:
internal final class DataDecoder<T: Decodable> {
internal final class func decode(_ data: Data) -> T? {
return try? JSONDecoder().decode(T.self, from: data)
}
}
The following is the model that the data is to be transformed into:
internal struct CurrentWeatherReport: Codable {
// MARK: Properties
internal var uvIndex: Int?
// MARK: CodingKeys
private enum CodingKeys: String, CodingKey {
case uvIndex
}
// MARK: Initializers
internal init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let uvIndexInteger = try container.decodeIfPresent(Int.self, forKey: .uvIndex) {
uvIndex = uvIndexInteger
} else if let uvIndexString = try container.decodeIfPresent(String.self, forKey: .uvIndex) {
uvIndex = Int(uvIndexString)
}
}
}
The following are the tests used to verify the decoding:
internal final class CurrentWeatherReportTests: XCTestCase {
internal final func test_CurrentWeather_ReturnsExpected_UVIndex_FromIntegerValue() {
let string =
"""
{
"uvIndex": 5
}
"""
let data = DataUtility.data(from: string)
let currentWeatherReport = DataDecoder<CurrentWeatherReport>.decode(data)
XCTAssertEqual(currentWeatherReport?.uvIndex, 5)
}
internal final func test_CurrentWeather_ReturnsExpected_UVIndex_FromStringValue() {
let string =
"""
{
"uvIndex": "5"
}
"""
let data = DataUtility.data(from: string)
let currentWeatherReport = DataDecoder<CurrentWeatherReport>.decode(data)
XCTAssertEqual(currentWeatherReport?.uvIndex, 5)
}
}
The difference between the tests is the value of uvIndex
in the JSON; one is a string and the other is an integer. I am expecting an integer, but I want to handle any cases in which the value may come back as a string rather than a number since that seems to be a common practice with a few APIs that I work with. However, my second test keeps failing with the following message: XCTAssertEqual failed: ("nil") is not equal to ("Optional(5)") -
Am I doing something incorrectly with regard to the Codable
protocol that is causing this failure? If not, then what is causing my second test to fail with this seemingly-simple cast?
回答1:
The issue you're seeing has to do with a combination of how your init(with: Decoder)
is written, and how DataDecoder
type decodes its type argument. Since the test is failing with nil != Optional(5)
, then either uvIndex
or currentWeatherReport
is nil
in currentWeatherReport?.uvIndex
.
Let's see how uvIndex
might be nil
. Since it's an Int?
, it gets a default value of nil
if not otherwise initialized, so that's a good place to start looking. How might it get assigned its default value?
internal init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
if let uvIndexInteger = try container.decodeIfPresent(Int.self, forKey: .uvIndex) {
// Clearly assigned to here
uvIndex = uvIndexInteger
} else if let uvIndexString = try container.decodeIfPresent(String.self, forKey: .uvIndex) {
// Clearly assigned to here
uvIndex = Int(uvIndexString)
}
// Hmm, what happens if neither condition is true?
}
Hmm. So, if decoding as both an Int
and a String
fail (because the value isn't present), you'll get nil
. But clearly, this isn't always the case, since the first test passes (and a value is indeed there).
So, on to the next failure mode: if an Int
is clearly being decoded, why is the String
not decoding properly? Well, when uvIndex
is a String
, the following decode call is still made:
try container.decodeIfPresent(Int.self, forKey: .uvIndex)
That call only returns nil
if a value isn't present (i.e. there's no value for the given key, or the value is explicitly null
); if a value is present but is not an Int
, the call will throw
.
Since the error being thrown isn't caught and explicitly handled, it propagates up immediately, never calling try container.decodeIfPresent(String.self, forKey: .uvIndex)
. Instead, the error bubbles up to where CurrentWeatherReport
is decoded:
internal final class func decode(_ data: Data) -> T? {
return try? JSONDecoder().decode(T.self, from: data)
}
Since this code try?
s, the error gets swallowed up, returning nil
. That nil
makes its way to the original currentWeatherReport?.uvIndex
call, which ends up being nil
not because uvIndex
was missing, but because the whole report failed to decode.
Likely, the init(with: Decoder)
implementation that fits your needs is more along the lines of the following:
internal init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// try? container.decode(...) returns nil if the value was the wrong type or was missing.
// You can also opt to try container.decode(...) and catch the error.
if let uvIndexInteger = try? container.decode(Int.self, forKey: .uvIndex) {
uvIndex = uvIndexInteger
} else if let uvIndexString = try? container.decode(String.self, forKey: .uvIndex) {
uvIndex = Int(uvIndexString)
} else {
// Not strictly necessary, but might be clearer.
uvIndex = nil
}
}
来源:https://stackoverflow.com/questions/48513704/what-is-preventing-my-conversion-from-string-to-int-when-decoding-using-swift-4