├── .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 |
--------------------------------------------------------------------------------