├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.swift ├── README.md ├── Sources └── OptionallyDecodable │ └── OptionallyDecodable.swift └── Tests └── OptionallyDecodableTests └── OptionallyDecodableTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | 21 | build-on-linux: 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - uses: actions/checkout@v2 27 | - name: Build 28 | run: swift build --enable-test-discovery -v 29 | - name: Run tests 30 | run: swift test --enable-test-discovery -v 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "OptionallyDecodable", 8 | products: [ 9 | // Products define the executables and libraries a package produces, and make them visible to other packages. 10 | .library( 11 | name: "OptionallyDecodable", 12 | targets: ["OptionallyDecodable"]), 13 | ], 14 | dependencies: [ 15 | // Dependencies declare other packages that this package depends on. 16 | // .package(url: /* package url */, from: "1.0.0"), 17 | ], 18 | targets: [ 19 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 20 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 21 | .target( 22 | name: "OptionallyDecodable", 23 | dependencies: []), 24 | .testTarget( 25 | name: "OptionallyDecodableTests", 26 | dependencies: ["OptionallyDecodable"]), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OptionallyDecodable 2 | 3 | OptionallyDecodable turns decoding failures into optionals instead of errors. 4 | 5 | Swift's `Codable` system allows for simple, declarative coding of objects into popular serialisation formats like JSON and back. 6 | 7 | However, when communicating with many REST APIs, the JSON response objects may not be as strict as Swift prefers them to be, at which point `JSONDecoder` gives up and throws an error. For nested objects, this error propagates to the top level object, resulting in failure to decode a large object because a single element in an object nested several levels deep was unexpectedly set to `null` or a hitherto unknown enum value. This applies even if you declare that property as optional, since only a JSON `null` or the absence of said value will be decoded as `nil`. 8 | 9 | OptionallyDecodable lets you annotate properties where you are unsure of their exact makeup either because of lacking documentation, erroneous implementations or for handling unexpected changes in the API contract. 10 | 11 | # Usage 12 | 13 | Simply annotate a property at the "failure point" you are comfortable with, e.g. in some cases you want to just make a single enum value optional if an unknown case was sent, whereas in some cases you may want to throw away a much bigger part of the response because it doesn't satisfy your requirements because of an over-eager endpoint that returns objects in an incomplete state. 14 | 15 | Given a JSON response such as: 16 | ```json 17 | { 18 | "code": "OK", 19 | "result": { 20 | "text": "abc", 21 | "number": 123 22 | } 23 | } 24 | ``` 25 | 26 | you may want to decode this into a structure such as: 27 | ```swift 28 | enum Code: String, Decodable { case success = "OK", failure = "FAIL" } 29 | 30 | struct Result: Decodable { 31 | let text: String 32 | let number: Int 33 | } 34 | 35 | struct Response: Decodable { 36 | let code: Code 37 | let result: Result 38 | } 39 | ``` 40 | 41 | If, for some reason, the API response suddenly leaves out either the `text` or `number` fields, both the `Result` object and the entire `Response` object will fail to decode. By annotating the `result` property with `@OptionallyDecodable` and changing it to be an optional `var`, its failure to decode will not affect the decoding of the entire `Response` object. 42 | 43 | Likewise, if it turns out that the backend, perhaps because of an erroneous microservice somewhere, sometimes sends the `number` as a string instead of as a number, you may make it optional and decorate it with `@OptionallyDecodable`. 44 | 45 | The same goes for enums, where the full range of possible return codes was not known when the enum was declared. If declared as `@OptionallyDecodable`, `code` will silently turn into `nil` (or `.none`) instead of throwing. 46 | 47 | # Installation 48 | 49 | ## Using Swift Package Manager 50 | In Xcode, choose File → Swift Packages → Add Package Dependency and paste a link to this repo. 51 | 52 | ## Without Swift Package Manager 53 | Simply copy `OptionallyDecodable.swift` into your project. 54 | -------------------------------------------------------------------------------- /Sources/OptionallyDecodable/OptionallyDecodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OptionallyDecodable.swift 3 | // OptionallyDecodable 4 | // 5 | // Created by Iggy Drougge on 2020-09-29. 6 | // 7 | 8 | /// Decodes a value when possible, otherwise yielding `nil`, for more resilient handling of JSON with unexpected shapes such as missing fields or incorrect types. Normally, this would throw a `DecodingError`, aborting the decoding process even of the parent object. 9 | @propertyWrapper public struct OptionallyDecodable { 10 | public let wrappedValue: Wrapped? 11 | 12 | public init(wrappedValue: Wrapped?) { 13 | self.wrappedValue = wrappedValue 14 | } 15 | } 16 | 17 | extension OptionallyDecodable: Decodable { 18 | public init(from decoder: Decoder) throws { 19 | let container = try? decoder.singleValueContainer() 20 | wrappedValue = try? container?.decode(Wrapped.self) 21 | } 22 | } 23 | 24 | /// We need this protocol to circumvent how the Swift compiler currently handles non-existing fields for property wrappers, always failing when there is no matching key. 25 | public protocol NullableCodable { 26 | associatedtype Wrapped: Decodable, ExpressibleByNilLiteral 27 | var wrappedValue: Wrapped { get } 28 | init(wrappedValue: Wrapped) 29 | } 30 | 31 | extension OptionallyDecodable: NullableCodable {} 32 | 33 | extension KeyedDecodingContainer { 34 | /// Necessary for handling non-existing fields, due to how Swift compiler currently synthesises decoders for property wrappers, always failing when there is no matching key. 35 | public func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { 36 | let decoded = try self.decodeIfPresent(T.self, forKey: key) ?? T(wrappedValue: nil) 37 | return decoded 38 | } 39 | } 40 | 41 | extension OptionallyDecodable: Encodable where Wrapped: Encodable { 42 | public func encode(to encoder: Encoder) throws { 43 | var container = encoder.singleValueContainer() 44 | try container.encode(wrappedValue) 45 | } 46 | } 47 | 48 | extension OptionallyDecodable: Equatable where Wrapped: Equatable {} 49 | 50 | extension OptionallyDecodable: Hashable where Wrapped: Hashable {} 51 | 52 | #if swift(>=5.5) 53 | extension OptionallyDecodable: Sendable where Wrapped: Sendable {} 54 | #endif 55 | -------------------------------------------------------------------------------- /Tests/OptionallyDecodableTests/OptionallyDecodableTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import OptionallyDecodable 3 | 4 | private struct Inner: Codable, Equatable{ 5 | let string: String 6 | let number: Int 7 | } 8 | 9 | #if swift(>=5.5) 10 | extension Inner: Sendable {} 11 | extension Outermost: Sendable {} 12 | extension Outermost.Inside: Sendable {} 13 | #endif 14 | 15 | private struct Outer: Codable, Equatable { 16 | @OptionallyDecodable 17 | var inner: Inner? 18 | } 19 | 20 | private struct Outermost: Decodable { 21 | struct Inside: Decodable { 22 | let innermost: Inner 23 | } 24 | @OptionallyDecodable 25 | var inside: Inside? 26 | } 27 | 28 | final class OptionallyDecodableTests: XCTestCase { 29 | 30 | func testDecodingEmptyJSON() throws { 31 | let json = "{}".data(using: .utf8)! 32 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 33 | XCTAssertNil(decoded.inner) 34 | } 35 | 36 | func testDecodingCorrectJSON() throws { 37 | let json = #""" 38 | { 39 | "inner": { 40 | "string": "Hej", 41 | "number": 1 42 | } 43 | } 44 | """#.data(using: .utf8)! 45 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 46 | XCTAssertNotNil(decoded.inner) 47 | } 48 | 49 | func testDecodingMalformedJSON() throws { 50 | let json = #""" 51 | { 52 | "inner": { 53 | "string": null, 54 | "number": 2 55 | } 56 | } 57 | """#.data(using: .utf8)! 58 | XCTAssertNoThrow(try JSONDecoder().decode(Outer.self, from: json), 59 | "Nested object should be deserialised as nil instead of throwing.") 60 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 61 | XCTAssertNil(decoded.inner, "Nested struct should be deserialised as nil.") 62 | } 63 | 64 | func testDecodingMissingFieldInJSON() throws { 65 | let json = #""" 66 | { 67 | "inner": { 68 | "string": null 69 | } 70 | } 71 | """#.data(using: .utf8)! 72 | XCTAssertNoThrow(try JSONDecoder().decode(Outer.self, from: json), 73 | "Nested object should be deserialised as nil instead of throwing.") 74 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 75 | XCTAssertNil(decoded.inner, "Nested struct should be deserialised as nil.") 76 | } 77 | 78 | func testDecodingNullInJSON() throws { 79 | let json = #""" 80 | { 81 | "inner": null 82 | } 83 | """#.data(using: .utf8)! 84 | XCTAssertNoThrow(try JSONDecoder().decode(Outer.self, from: json), 85 | "Nested object should be deserialised as nil instead of throwing.") 86 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 87 | XCTAssertNil(decoded.inner, "Nested struct should be deserialised as nil.") 88 | } 89 | 90 | func testPropagationOfNestedValidValues() throws { 91 | let json = #""" 92 | { 93 | "inside": { 94 | "innermost": { 95 | "string": "a", 96 | "number": 1 97 | } 98 | } 99 | } 100 | """#.data(using: .utf8)! 101 | let decoded = try JSONDecoder().decode(Outermost.self, from: json) 102 | XCTAssertNotNil(decoded.inside) 103 | XCTAssertNotNil(decoded.inside?.innermost) 104 | } 105 | 106 | func testPropagationOfNestedInvalidValues() throws { 107 | let json = #""" 108 | { 109 | "inside": { 110 | "innermost": { 111 | "string": "a" 112 | } 113 | } 114 | } 115 | """#.data(using: .utf8)! 116 | let decoded = try JSONDecoder().decode(Outermost.self, from: json) 117 | XCTAssertNil(decoded.inside, "Nested object should be nil because it contains a mis-shaped object.") 118 | } 119 | 120 | func testDecodingEnum() throws { 121 | enum Test: String, Decodable { 122 | case ok = "CODING_OK" 123 | case bad = "CODING_BAD" 124 | } 125 | struct Object: Decodable { 126 | @OptionallyDecodable 127 | var value: Test? 128 | } 129 | let goodJSON = #"{ "value": "CODING_OK" }"#.data(using: .utf8)! 130 | let good = try JSONDecoder().decode(Object.self, from: goodJSON) 131 | XCTAssertEqual(good.value, .ok) 132 | 133 | let badJSON = #"{ "value": "NEITHER" }"#.data(using: .utf8)! 134 | XCTAssertNoThrow(try JSONDecoder().decode(Object.self, from: badJSON), 135 | "Invalid enum value should be decoded as nil instead of throwing.") 136 | let bad = try JSONDecoder().decode(Object.self, from: badJSON) 137 | XCTAssertNil(bad.value, "Invalid enum value should be decoded as nil.") 138 | } 139 | 140 | func testEncoding() throws { 141 | let object = Outer(inner: Inner(string: "abc", number: 123)) 142 | _ = try JSONEncoder().encode(object) 143 | } 144 | 145 | func testRoundtrip() throws { 146 | let json = #""" 147 | { 148 | "inner": { 149 | "string": "abc", 150 | "number": 123 151 | } 152 | } 153 | """#.data(using: .utf8)! 154 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 155 | let encoded = try JSONEncoder().encode(decoded) 156 | let recoded = try JSONDecoder().decode(Outer.self, from: encoded) 157 | XCTAssertNotNil(recoded.inner, 158 | "Object should survive encode/decode roundtrip if correct.") 159 | XCTAssertEqual(recoded, Outer(inner: Inner(string: "abc", number: 123))) 160 | } 161 | 162 | func testRoundtripOfMalformedJSON() throws { 163 | let json = #""" 164 | { 165 | "inner": { 166 | "string": null, 167 | "number": 123 168 | } 169 | } 170 | """#.data(using: .utf8)! 171 | let decoded = try JSONDecoder().decode(Outer.self, from: json) 172 | let encoded = try JSONEncoder().encode(decoded) 173 | let recoded = try JSONDecoder().decode(Outer.self, from: encoded) 174 | XCTAssertNil(recoded.inner, 175 | "Object should survive encode/decode roundtrip if correct.") 176 | } 177 | } 178 | --------------------------------------------------------------------------------