Skip to content

Commit 2a07971

Browse files
PM-14646: Fix JSON decoding errors (#1122)
1 parent 91a1853 commit 2a07971

File tree

10 files changed

+292
-8
lines changed

10 files changed

+292
-8
lines changed

BitwardenShared/Core/Platform/Utilities/AnyCodable.swift

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ public enum AnyCodable: Codable, Equatable, Sendable {
66
/// The wrapped value is a bool value.
77
case bool(Bool)
88

9+
/// The wrapped value is a double value.
10+
case double(Double)
11+
912
/// The wrapped value is an int value.
1013
case int(Int)
1114

@@ -24,6 +27,9 @@ public enum AnyCodable: Codable, Equatable, Sendable {
2427
self = .bool(boolValue)
2528
} else if let intValue = try? container.decode(Int.self) {
2629
self = .int(intValue)
30+
} else if let doubleValue = try? container.decode(Double.self) {
31+
// Double needs to attempt decoding after `Int` otherwise it will capture any integer values.
32+
self = .double(doubleValue)
2733
} else if container.decodeNil() {
2834
self = .null
2935
} else if let stringValue = try? container.decode(String.self) {
@@ -44,6 +50,8 @@ public enum AnyCodable: Codable, Equatable, Sendable {
4450
switch self {
4551
case let .bool(boolValue):
4652
try container.encode(boolValue)
53+
case let .double(doubleValue):
54+
try container.encode(doubleValue)
4755
case let .int(intValue):
4856
try container.encode(intValue)
4957
case .null:
@@ -61,10 +69,36 @@ extension AnyCodable {
6169
return boolValue
6270
}
6371

64-
/// Returns the associated int value if the type is `int`.
72+
/// Returns the associated double value if the type is `double` or could be converted to `Double`.
73+
var doubleValue: Double? {
74+
switch self {
75+
case let .bool(boolValue):
76+
boolValue ? 1 : 0
77+
case let .double(doubleValue):
78+
doubleValue
79+
case let .int(intValue):
80+
Double(intValue)
81+
case .null:
82+
nil
83+
case let .string(stringValue):
84+
Double(stringValue)
85+
}
86+
}
87+
88+
/// Returns the associated int value if the type is `int` or could be converted to `Int`.
6589
var intValue: Int? {
66-
guard case let .int(intValue) = self else { return nil }
67-
return intValue
90+
switch self {
91+
case let .bool(boolValue):
92+
boolValue ? 1 : 0
93+
case let .double(doubleValue):
94+
Int(doubleValue)
95+
case let .int(intValue):
96+
intValue
97+
case .null:
98+
nil
99+
case let .string(stringValue):
100+
Int(stringValue)
101+
}
68102
}
69103

70104
/// Returns the associated string value if the type is `string`.

BitwardenShared/Core/Platform/Utilities/AnyCodableTests.swift

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ class AnyCodableTests: BitwardenTestCase {
2727
"requireNumbers": false,
2828
"requireSpecial": false,
2929
"enforceOnLogin": false,
30-
"type": "password"
30+
"type": "password",
31+
"minutes": 1.5
3132
}
3233
"""
3334

@@ -45,10 +46,33 @@ class AnyCodableTests: BitwardenTestCase {
4546
"requireSpecial": AnyCodable.bool(false),
4647
"enforceOnLogin": AnyCodable.bool(false),
4748
"type": AnyCodable.string("password"),
49+
"minutes": AnyCodable.double(1.5),
4850
]
4951
)
5052
}
5153

54+
/// `doubleValue` returns the double associated value if the type is a `double` or could be
55+
/// converted to `Double`.
56+
func test_doubleValue() {
57+
XCTAssertEqual(AnyCodable.bool(true).doubleValue, 1)
58+
XCTAssertEqual(AnyCodable.bool(false).doubleValue, 0)
59+
60+
XCTAssertEqual(AnyCodable.double(2).doubleValue, 2)
61+
XCTAssertEqual(AnyCodable.double(3.1).doubleValue, 3.1)
62+
XCTAssertEqual(AnyCodable.double(3.8).doubleValue, 3.8)
63+
XCTAssertEqual(AnyCodable.double(-5.5).doubleValue, -5.5)
64+
65+
XCTAssertEqual(AnyCodable.int(1).doubleValue, 1)
66+
XCTAssertEqual(AnyCodable.int(5).doubleValue, 5)
67+
XCTAssertEqual(AnyCodable.int(15).doubleValue, 15)
68+
69+
XCTAssertNil(AnyCodable.null.doubleValue)
70+
71+
XCTAssertEqual(AnyCodable.string("1").doubleValue, 1)
72+
XCTAssertEqual(AnyCodable.string("1.5").doubleValue, 1.5)
73+
XCTAssertNil(AnyCodable.string("abc").doubleValue)
74+
}
75+
5276
/// `AnyCodable` can be used to encode JSON.
5377
func test_encode() throws {
5478
let dictionary: [String: AnyCodable] = [
@@ -60,6 +84,7 @@ class AnyCodableTests: BitwardenTestCase {
6084
"requireSpecial": AnyCodable.bool(false),
6185
"enforceOnLogin": AnyCodable.bool(false),
6286
"type": AnyCodable.string("password"),
87+
"minutes": AnyCodable.double(1.5),
6388
]
6489

6590
let encoder = JSONEncoder()
@@ -74,6 +99,7 @@ class AnyCodableTests: BitwardenTestCase {
7499
"enforceOnLogin" : false,
75100
"minComplexity" : null,
76101
"minLength" : 12,
102+
"minutes" : 1.5,
77103
"requireLower" : true,
78104
"requireNumbers" : false,
79105
"requireSpecial" : false,
@@ -84,13 +110,25 @@ class AnyCodableTests: BitwardenTestCase {
84110
)
85111
}
86112

87-
/// `intValue` returns the int associated value if the type is an `int`.
113+
/// `intValue` returns the int associated value if the type is an `int` or could be converted
114+
/// to `Int`.
88115
func test_intValue() {
116+
XCTAssertEqual(AnyCodable.bool(true).intValue, 1)
117+
XCTAssertEqual(AnyCodable.bool(false).intValue, 0)
118+
119+
XCTAssertEqual(AnyCodable.double(2).intValue, 2)
120+
XCTAssertEqual(AnyCodable.double(3.1).intValue, 3)
121+
XCTAssertEqual(AnyCodable.double(3.8).intValue, 3)
122+
XCTAssertEqual(AnyCodable.double(-5.5).intValue, -5)
123+
89124
XCTAssertEqual(AnyCodable.int(1).intValue, 1)
90125
XCTAssertEqual(AnyCodable.int(5).intValue, 5)
126+
XCTAssertEqual(AnyCodable.int(15).intValue, 15)
91127

92-
XCTAssertNil(AnyCodable.bool(false).intValue)
93128
XCTAssertNil(AnyCodable.null.intValue)
129+
130+
XCTAssertEqual(AnyCodable.string("1").intValue, 1)
131+
XCTAssertNil(AnyCodable.string("1.5").intValue)
94132
XCTAssertNil(AnyCodable.string("abc").intValue)
95133
}
96134

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import OSLog
2+
3+
// MARK: - DefaultValueProvider
4+
5+
/// A protocol for defining a default value for a `Decodable` type if an invalid or missing value
6+
/// is received.
7+
///
8+
protocol DefaultValueProvider: Decodable {
9+
/// The default value to use if the value to decode is invalid or missing.
10+
static var defaultValue: Self { get }
11+
}
12+
13+
// MARK: - DefaultValue
14+
15+
/// A property wrapper that will default the wrapped value to a default value if decoding fails.
16+
/// This is useful for decoding types which may not be present in the response or to prevent a
17+
/// decoding failure if an invalid value is received.
18+
///
19+
@propertyWrapper
20+
struct DefaultValue<T: DefaultValueProvider> {
21+
// MARK: Properties
22+
23+
/// The wrapped value.
24+
let wrappedValue: T
25+
}
26+
27+
// MARK: - Decodable
28+
29+
extension DefaultValue: Decodable {
30+
init(from decoder: any Decoder) throws {
31+
let container = try decoder.singleValueContainer()
32+
do {
33+
wrappedValue = try container.decode(T.self)
34+
} catch {
35+
if let intValue = try? container.decode(Int.self) {
36+
Logger.application.warning(
37+
"""
38+
Cannot initialize \(T.self) from invalid Int value \(intValue, privacy: .private), \
39+
defaulting to \(String(describing: T.defaultValue)).
40+
"""
41+
)
42+
} else if let stringValue = try? container.decode(String.self) {
43+
Logger.application.warning(
44+
"""
45+
Cannot initialize \(T.self) from invalid String value \(stringValue, privacy: .private), \
46+
defaulting to \(String(describing: T.defaultValue))
47+
"""
48+
)
49+
} else {
50+
Logger.application.warning(
51+
"""
52+
Cannot initialize \(T.self) from invalid unknown valid, \
53+
defaulting to \(String(describing: T.defaultValue))
54+
"""
55+
)
56+
}
57+
wrappedValue = T.defaultValue
58+
}
59+
}
60+
}
61+
62+
// MARK: - Encodable
63+
64+
extension DefaultValue: Encodable where T: Encodable {
65+
func encode(to encoder: any Encoder) throws {
66+
var container = encoder.singleValueContainer()
67+
try container.encode(wrappedValue)
68+
}
69+
}
70+
71+
// MARK: - Equatable
72+
73+
extension DefaultValue: Equatable where T: Equatable {}
74+
75+
// MARK: - Hashable
76+
77+
extension DefaultValue: Hashable where T: Hashable {}
78+
79+
// MARK: - KeyedDecodingContainer
80+
81+
extension KeyedDecodingContainer {
82+
/// When decoding a `DefaultValue` wrapped value, if the property doesn't exist, default to the
83+
/// type's default value.
84+
///
85+
/// - Parameters:
86+
/// - type: The type of value to attempt to decode.
87+
/// - key: The key used to decode the value.
88+
///
89+
func decode<T>(_ type: DefaultValue<T>.Type, forKey key: Key) throws -> DefaultValue<T> {
90+
if let value = try decodeIfPresent(DefaultValue<T>.self, forKey: key) {
91+
return value
92+
} else {
93+
Logger.application.warning(
94+
"Missing value for \(T.self), defaulting to \(String(describing: T.defaultValue))"
95+
)
96+
return DefaultValue(wrappedValue: T.defaultValue)
97+
}
98+
}
99+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class DefaultValueTests: BitwardenTestCase {
6+
// MARK: Types
7+
8+
enum ValueType: String, Codable, DefaultValueProvider {
9+
case one, two, three
10+
11+
static var defaultValue: ValueType { .one }
12+
}
13+
14+
struct Model: Codable, Equatable {
15+
@DefaultValue var value: ValueType
16+
}
17+
18+
// MARK: Tests
19+
20+
/// `DefaultValue` encodes the wrapped value.
21+
func test_encode() throws {
22+
let subject = Model(value: .two)
23+
let data = try JSONEncoder().encode(subject)
24+
XCTAssertEqual(String(data: data, encoding: .utf8), #"{"value":"two"}"#)
25+
}
26+
27+
/// Decoding a `DefaultValue` wrapped value will use the default value if an array cannot be
28+
/// initialized to the type.
29+
func test_decode_invalidArray() throws {
30+
let json = #"{"value": ["three"]}"#
31+
let data = try XCTUnwrap(json.data(using: .utf8))
32+
let subject = try JSONDecoder().decode(Model.self, from: data)
33+
XCTAssertEqual(subject, Model(value: .one))
34+
}
35+
36+
/// Decoding a `DefaultValue` wrapped value will use the default value if an int value cannot
37+
/// be initialized to the type.
38+
func test_decode_invalidInt() throws {
39+
let json = #"{"value": 5}"#
40+
let data = try XCTUnwrap(json.data(using: .utf8))
41+
let subject = try JSONDecoder().decode(Model.self, from: data)
42+
XCTAssertEqual(subject, Model(value: .one))
43+
}
44+
45+
/// Decoding a `DefaultValue` wrapped value will use the default value if a string value cannot
46+
/// be initialized to the type.
47+
func test_decode_invalidString() throws {
48+
let json = #"{"value": "unknown"}"#
49+
let data = try XCTUnwrap(json.data(using: .utf8))
50+
let subject = try JSONDecoder().decode(Model.self, from: data)
51+
XCTAssertEqual(subject, Model(value: .one))
52+
}
53+
54+
/// Decoding a `DefaultValue` wrapped value will use the default value if the value is
55+
/// unknown in the JSON.
56+
func test_decode_missing() throws {
57+
let json = #"{}"#
58+
let data = try XCTUnwrap(json.data(using: .utf8))
59+
let subject = try JSONDecoder().decode(Model.self, from: data)
60+
XCTAssertEqual(subject, Model(value: .one))
61+
}
62+
63+
/// Decoding a `DefaultValue` wrapped value will use the default value if the value is `null`
64+
/// in the JSON.
65+
func test_decode_null() throws {
66+
let json = #"{"value": null}"#
67+
let data = try XCTUnwrap(json.data(using: .utf8))
68+
let subject = try JSONDecoder().decode(Model.self, from: data)
69+
XCTAssertEqual(subject, Model(value: .one))
70+
}
71+
72+
/// Decoding a `DefaultValue` wrapped value will decode the enum value from the JSON.
73+
func test_decode_value() throws {
74+
let json = #"{"value": "three"}"#
75+
let data = try XCTUnwrap(json.data(using: .utf8))
76+
let subject = try JSONDecoder().decode(Model.self, from: data)
77+
XCTAssertEqual(subject, Model(value: .three))
78+
}
79+
}

BitwardenShared/Core/Vault/Models/API/CipherSecureNoteModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ struct CipherSecureNoteModel: Codable, Equatable {
44
// MARK: Properties
55

66
/// The type of secure note.
7-
let type: SecureNoteType
7+
@DefaultValue var type: SecureNoteType
88
}

BitwardenShared/Core/Vault/Models/Enum/CipherRepromptType.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@ enum CipherRepromptType: Int, Codable {
77
/// The user should be prompted for their master password prior to using the cipher password.
88
case password = 1
99
}
10+
11+
// MARK: - DefaultValueProvider
12+
13+
extension CipherRepromptType: DefaultValueProvider {
14+
static var defaultValue: CipherRepromptType { .none }
15+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class CipherRepromptTypeTests: BitwardenTestCase {
6+
/// `defaultValue` returns the default value for the type if an invalid or missing value is
7+
/// received when decoding the type.
8+
func test_defaultValue() {
9+
XCTAssertEqual(CipherRepromptType.defaultValue, .none)
10+
}
11+
}

BitwardenShared/Core/Vault/Models/Enum/SecureNoteType.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,9 @@ enum SecureNoteType: Int, Codable {
44
/// A generic note.
55
case generic = 0
66
}
7+
8+
// MARK: - DefaultValueProvider
9+
10+
extension SecureNoteType: DefaultValueProvider {
11+
static var defaultValue: SecureNoteType { .generic }
12+
}

BitwardenShared/Core/Vault/Models/Response/CipherDetailsResponseModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ struct CipherDetailsResponseModel: JSONResponse, Equatable {
6262

6363
/// Whether the user needs to be re-prompted for their master password prior to autofilling the
6464
/// cipher's password.
65-
let reprompt: CipherRepromptType
65+
@DefaultValue var reprompt: CipherRepromptType
6666

6767
/// The date the cipher was last updated.
6868
let revisionDate: Date
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class SecureNoteTypeTests: BitwardenTestCase {
6+
/// `defaultValue` returns the default value for the type if an invalid or missing value is
7+
/// received when decoding the type.
8+
func test_defaultValue() {
9+
XCTAssertEqual(SecureNoteType.defaultValue, .generic)
10+
}
11+
}

0 commit comments

Comments
 (0)