├── .gitignore
├── Gemfile
├── Tests
├── LinuxMain.swift
└── ResilientDecodingTests
│ ├── MemoryTests.swift
│ ├── ResilientDecodingErrorReporterTests.swift
│ ├── ResilientOptionalTests.swift
│ ├── ResilientDecodingTests.swift
│ ├── BugTests.swift
│ ├── ResilientArrayTests.swift
│ ├── ResilientDictionaryTests.swift
│ ├── ResilientRawRepresentableArrayTests.swift
│ ├── XCTestManifests.swift
│ ├── ResilientRawRepresentableTests.swift
│ └── ResilientRawRepresentableDictionaryTests.swift
├── .swiftpm
└── xcode
│ ├── package.xcworkspace
│ └── contents.xcworkspacedata
│ └── xcshareddata
│ └── xcschemes
│ └── ResilientDecoding.xcscheme
├── CODE_OF_CONDUCT.md
├── Package.swift
├── ResilientDecoding.podspec
├── .github
└── workflows
│ └── ci.yml
├── Contributing.md
├── LICENSE
├── Sources
└── ResilientDecoding
│ ├── ResilientOptional.swift
│ ├── ResilientArray+DecodingOutcome.swift
│ ├── ResilientArray.swift
│ ├── ResilientDictionary.swift
│ ├── ResilientDictionary+DecodingOutcome.swift
│ ├── Resilient.swift
│ ├── ResilientRawRepresentable.swift
│ └── ErrorReporting.swift
├── Gemfile.lock
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | /.build
3 | /Packages
4 | /*.xcodeproj
5 | xcuserdata/
6 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | source 'https://rubygems.org'
2 |
3 | gem 'cocoapods', '~> 1.15.0'
4 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import ResilientDecodingTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += ResilientDecodingTests.__allTests()
7 |
8 | XCTMain(tests)
9 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | Airbnb has adopted a Code of Conduct that we expect project participants to adhere to. Please [read the full Code of Conduct text](https://airbnb.io/codeofconduct/) so that you can understand what actions will and will not be tolerated. Report violations to the maintainers of this project or to [opensource-conduct@airbnb.com](mailto:opensource-conduct@airbnb.com).
2 |
3 | Reports sent to [opensource-conduct@airbnb.com](mailto:opensource-conduct@airbnb.com) are received by Airbnb's open source code of conduct moderation team, which is composed of Airbnb employees. All communications are private and confidential.
4 |
--------------------------------------------------------------------------------
/Package.swift:
--------------------------------------------------------------------------------
1 | // swift-tools-version:5.2
2 | import PackageDescription
3 |
4 | let package = Package(
5 | name: "ResilientDecoding",
6 | platforms: [
7 | .iOS(.v12),
8 | .tvOS(.v12),
9 | .watchOS(.v5),
10 | .macOS(.v10_14),
11 | ],
12 | products: [
13 | .library(
14 | name: "ResilientDecoding",
15 | targets: ["ResilientDecoding"]),
16 | ],
17 | targets: [
18 | .target(
19 | name: "ResilientDecoding",
20 | dependencies: []),
21 | .testTarget(
22 | name: "ResilientDecodingTests",
23 | dependencies: ["ResilientDecoding"]),
24 | ]
25 | )
26 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/MemoryTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/2/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | final class MemoryTests: XCTestCase {
8 | func testNoOverheadInRelease() throws {
9 | #if !DEBUG
10 | struct StandardProperties {
11 | let foo: String
12 | let bar: Int
13 | }
14 | struct ResilientProperties {
15 | @Resilient var foo: String
16 | @Resilient var bar: Int
17 | }
18 | XCTAssertEqual(MemoryLayout.size, MemoryLayout.size)
19 | #endif
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/ResilientDecoding.podspec:
--------------------------------------------------------------------------------
1 | Pod::Spec.new do |s|
2 | s.name = 'ResilientDecoding'
3 | s.version = '1.2.0'
4 | s.license = 'MIT'
5 | s.summary = 'A library you can use to partially recover from decoding errors'
6 | s.homepage = 'https://github.com/airbnb/ResilientDecoding'
7 | s.authors = 'George Leontiev'
8 | s.source = { :git => 'https://github.com/airbnb/ResilientDecoding.git', :tag => s.version }
9 | s.swift_version = '5.2'
10 | s.source_files = 'Sources/ResilientDecoding/**/*.{swift}'
11 | s.ios.deployment_target = '12.0'
12 | s.tvos.deployment_target = '12.0'
13 | s.watchos.deployment_target = '5.0'
14 | s.macos.deployment_target = '10.14'
15 | end
16 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 |
9 | jobs:
10 | build-macos:
11 | runs-on: macOS-14
12 | steps:
13 | # Checks-out the repo. More at: https://github.com/actions/checkout
14 | - uses: actions/checkout@v2
15 | - name: Run on Xcode 15.4
16 | run: sudo xcode-select -switch /Applications/Xcode_15.4.app
17 | - name: Test in Debug
18 | run: swift test -c debug
19 | - name: Test in Release
20 | run: swift test -c release
21 |
22 | build-linux:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: actions/checkout@v2
26 | - uses: Didstopia/SwiftAction@v1.0.2
27 | with:
28 | swift-action: 'test'
29 |
30 | validate-podspec:
31 | runs-on: macOS-14
32 | steps:
33 | - uses: actions/checkout@v2
34 | - name: Validate Podspec
35 | run: bundle install && bundle exec pod lib lint --verbose --fail-fast
36 |
--------------------------------------------------------------------------------
/Contributing.md:
--------------------------------------------------------------------------------
1 | ### One issue or bug per Pull Request
2 |
3 | Keep your Pull Requests small. Small PRs are easier to reason about which makes them significantly more likely to get merged.
4 |
5 | ### Issues before features
6 |
7 | If you want to add a feature, consider filing an [Issue](../../issues). An Issue can provide the opportunity to discuss the requirements and implications of a feature with you before you start writing code. This is not a hard requirement, however. Submitting a Pull Request to demonstrate an idea in code is also acceptable, it just carries more risk of change.
8 |
9 | ### Backwards compatibility
10 |
11 | Respect the minimum deployment target. If you are adding code that uses new APIs, make sure to prevent older clients from crashing or misbehaving. Our CI runs against our minimum deployment targets, so you will not get a green build unless your code is backwards compatible.
12 |
13 | ### Forwards compatibility
14 |
15 | Please do not write new code using deprecated APIs.
16 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Airbnb
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientDecodingErrorReporterTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/2/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | private struct ResilientArrayWrapper: Decodable {
8 | @Resilient var resilientArray: [Int]
9 | @Resilient var resilientEnum: ResilientEnum?
10 | }
11 |
12 | final class ResilientDecodingErrorReporterTests: XCTestCase {
13 |
14 | func testDebugDescription() throws {
15 | let decoder = JSONDecoder()
16 | let errorReporter = decoder.enableResilientDecodingErrorReporting()
17 | _ = try decoder.decode(ResilientArrayWrapper.self, from: """
18 | {
19 | "resilientArray": [1, "2", 3, "4", 5],
20 | "resilientEnum": "novel",
21 | }
22 | """.data(using: .utf8)!)
23 | guard let errorDigest = errorReporter.flushReportedErrors() else {
24 | XCTFail()
25 | return
26 | }
27 | #if DEBUG
28 | XCTAssertEqual(errorDigest.debugDescription, """
29 | resilientArray
30 | Index 1
31 | - Could not decode as `Int`
32 | Index 3
33 | - Could not decode as `Int`
34 | resilientEnum
35 | - Unknown novel value "novel" (this error is not reported by default)
36 | """)
37 | #endif
38 | // Ensure that the error digest was actually flushed
39 | XCTAssertNil(errorReporter.flushReportedErrors())
40 | }
41 |
42 | }
43 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientOptional.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | // MARK: - Decoding
7 |
8 | /**
9 | Synthesized `Decodable` initializers are effectively equivalent to writing the following initializer:
10 | ```
11 | init(from decoder: Decoder) throws {
12 | let container = try decoder.container(keyedBy: SynthesizedCodingKeys.self)
13 | self.propertyA = try container.decode(TypeOfPropertyA.self, forKey: .propertyA)
14 | self.propertyB = try container.decode(TypeOfPropertyB.self, forKey: .propertyB)
15 | …and so on
16 | }
17 | ```
18 | By declaring these public methods here, if `TypeOfPropertyA` is a specialization of `Resilient` such that it matches one of the following method signatures, Swift will call that overload of the `decode(_:forKey)` method instead of the default implementation provided by `Foundation`. This allows us to perform custom logic to _resiliently_ recover from decoding errors.
19 | */
20 | extension KeyedDecodingContainer {
21 |
22 | /**
23 | Decodes a `Resilient` value, substituting `nil` if an error is encountered (in most cases, this will be a `Resilient` `Optional` value).
24 | The synthesized `init(from:)` of a struct with a propery declared like this: `@Resilient var title: String?` will call this method to decode that property.
25 | */
26 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient {
27 | resilientlyDecode(valueForKey: key, fallback: nil)
28 | }
29 |
30 | }
31 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientOptionalTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | private struct ResilientOptionalWrapper: Decodable {
8 | @Resilient var resilientOptional: Int?
9 | }
10 |
11 | final class ResilientOptionalTests: XCTestCase {
12 |
13 | func testDecodesValidInputWithoutErrors() throws {
14 | let mock = try decodeMock(ResilientOptionalWrapper.self, """
15 | {
16 | "resilientOptional": 1,
17 | }
18 | """)
19 | XCTAssertEqual(mock.resilientOptional, 1)
20 | #if DEBUG
21 | XCTAssert(mock.$resilientOptional.outcome.is(.decodedSuccessfully))
22 | XCTAssertNil(mock.$resilientOptional.error)
23 | #endif
24 | }
25 |
26 | func testDecodesWhenMissingKeyWithoutErrors() throws {
27 | let mock = try decodeMock(ResilientOptionalWrapper.self, """
28 | {
29 | }
30 | """)
31 | XCTAssertNil(mock.resilientOptional)
32 | #if DEBUG
33 | XCTAssert(mock.$resilientOptional.outcome.is(.keyNotFound))
34 | XCTAssertNil(mock.$resilientOptional.error)
35 | #endif
36 | }
37 |
38 | func testDecodesNullValueWithoutErrors() throws {
39 | let mock = try decodeMock(ResilientOptionalWrapper.self, """
40 | {
41 | "resilientOptional": null
42 | }
43 | """)
44 | XCTAssertNil(mock.resilientOptional)
45 | #if DEBUG
46 | XCTAssert(mock.$resilientOptional.outcome.is(.valueWasNil))
47 | XCTAssertNil(mock.$resilientOptional.error)
48 | #endif
49 | }
50 |
51 | func testResilientlyDecodesInvalidValue() throws {
52 | let mock = try decodeMock(ResilientOptionalWrapper.self, """
53 | {
54 | "resilientOptional": "INVALID",
55 | }
56 | """,
57 | expectedErrorCount: 1)
58 | XCTAssertNil(mock.resilientOptional)
59 | #if DEBUG
60 | XCTAssert(mock.$resilientOptional.outcome.is(.recoveredFromError(wasReported: true)))
61 | XCTAssertNotNil(mock.$resilientOptional.error)
62 | #endif
63 | }
64 |
65 | }
66 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientDecodingTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/24/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | enum ResilientEnum: String, ResilientRawRepresentable {
8 | case existing
9 | case unknown
10 | }
11 |
12 | enum ResilientEnumWithFallback: String, ResilientRawRepresentable {
13 | case existing
14 | case unknown
15 | static var decodingFallback: Self { .unknown }
16 | }
17 |
18 | enum ResilientFrozenEnum: String, ResilientRawRepresentable {
19 | case existing
20 | case unknown
21 | static var isFrozen: Bool { true }
22 | }
23 |
24 | enum ResilientFrozenEnumWithFallback: String, ResilientRawRepresentable {
25 | case existing
26 | case unknown
27 | static var isFrozen: Bool { true }
28 | static var decodingFallback: Self { .unknown }
29 | }
30 |
31 | extension XCTestCase {
32 |
33 | func decodeMock(_ type: T.Type, _ string: String, expectedErrorCount: Int = 0) throws -> T {
34 | let decoder = JSONDecoder()
35 | let data = string.data(using: .utf8)!
36 | let (decoded, errorDigest) = try decoder.decode(T.self, from: data, reportResilientDecodingErrors: true)
37 | XCTAssertEqual(errorDigest?.errors.count ?? 0, expectedErrorCount)
38 | return decoded
39 | }
40 |
41 | }
42 |
43 | #if DEBUG
44 | /**
45 | Since `Error` is not `Equatable`, we use this `enum` to verify the correct outcome was encountered
46 | */
47 | enum ExpectedDecodingOutcome {
48 | case decodedSuccessfully
49 | case keyNotFound
50 | case valueWasNil
51 | case recoveredFromError(wasReported: Bool)
52 | }
53 |
54 | extension ResilientDecodingOutcome {
55 | func `is`(_ expected: ExpectedDecodingOutcome) -> Bool {
56 | switch (self, expected) {
57 | case
58 | (.decodedSuccessfully, .decodedSuccessfully),
59 | (.keyNotFound, .keyNotFound),
60 | (.valueWasNil, .valueWasNil):
61 | return true
62 | case let (.recoveredFrom(_, lhs), .recoveredFromError(rhs)):
63 | return lhs == rhs
64 | default:
65 | return false
66 | }
67 | }
68 | }
69 | #endif
70 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/BugTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 5/6/20.
2 | // Copyright © 2020 Airbnb Inc. All rights reserved.
3 |
4 | import XCTest
5 | import ResilientDecoding
6 |
7 | /**
8 | Tests for bugs that were encountered after releasing. The commit which introduces a test here should also introduce a fix.
9 | */
10 | final class BugTests: XCTestCase {
11 |
12 | /**
13 | This issue was causing this test to fail previously: https://forums.swift.org/t/url-fails-to-decode-when-it-is-a-generic-argument-and-genericargument-from-decoder-is-used/36238
14 | */
15 | func testResilientURLsDecodeSuccessfully() throws {
16 | struct RawRepresentable: ResilientRawRepresentable {
17 | let rawValue: URL
18 | }
19 | struct FrozenRawRepresentable: ResilientRawRepresentable {
20 | let rawValue: URL
21 | static var isFrozen: Bool { true }
22 | }
23 | struct Mock: Decodable {
24 | @Resilient var optional: URL?
25 | @Resilient var array: [URL]
26 | @Resilient var dictionary: [String: URL]
27 | @Resilient var rawRepresentable: RawRepresentable?
28 | @Resilient var frozenRawRepresentable: FrozenRawRepresentable?
29 | }
30 | let mock = try decodeMock(Mock.self, """
31 | {
32 | "optional": "https://www.airbnb.com",
33 | "array": [
34 | "https://www.airbnb.com",
35 | "https://en.wikipedia.org/wiki/Diceware",
36 | ],
37 | "dictionary": {
38 | "Airbnb": "https://www.airbnb.com",
39 | "Diceware": "https://en.wikipedia.org/wiki/Diceware",
40 | },
41 | "rawRepresentable": "https://www.airbnb.com",
42 | "frozenRawRepresentable": "https://www.airbnb.com",
43 | }
44 | """)
45 | XCTAssertNotNil(mock.optional)
46 | XCTAssertEqual(mock.array.count, 2)
47 | XCTAssertEqual(mock.dictionary.count, 2)
48 | XCTAssertNotNil(mock.rawRepresentable)
49 | XCTAssertNotNil(mock.frozenRawRepresentable)
50 | #if DEBUG
51 | XCTAssertNil(mock.$optional.error)
52 | XCTAssertNil(mock.$array.error)
53 | XCTAssertNil(mock.$dictionary.error)
54 | XCTAssertNil(mock.$rawRepresentable.error)
55 | XCTAssertNil(mock.$frozenRawRepresentable.error)
56 | #endif
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/.swiftpm/xcode/xcshareddata/xcschemes/ResilientDecoding.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
48 |
54 |
55 |
56 |
57 |
58 |
68 |
69 |
75 |
76 |
82 |
83 |
84 |
85 |
87 |
88 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | GEM
2 | remote: https://rubygems.org/
3 | specs:
4 | CFPropertyList (3.0.7)
5 | base64
6 | nkf
7 | rexml
8 | activesupport (7.2.1)
9 | base64
10 | bigdecimal
11 | concurrent-ruby (~> 1.0, >= 1.3.1)
12 | connection_pool (>= 2.2.5)
13 | drb
14 | i18n (>= 1.6, < 2)
15 | logger (>= 1.4.2)
16 | minitest (>= 5.1)
17 | securerandom (>= 0.3)
18 | tzinfo (~> 2.0, >= 2.0.5)
19 | addressable (2.8.7)
20 | public_suffix (>= 2.0.2, < 7.0)
21 | algoliasearch (1.27.5)
22 | httpclient (~> 2.8, >= 2.8.3)
23 | json (>= 1.5.1)
24 | atomos (0.1.3)
25 | base64 (0.2.0)
26 | bigdecimal (3.1.8)
27 | claide (1.1.0)
28 | cocoapods (1.15.2)
29 | addressable (~> 2.8)
30 | claide (>= 1.0.2, < 2.0)
31 | cocoapods-core (= 1.15.2)
32 | cocoapods-deintegrate (>= 1.0.3, < 2.0)
33 | cocoapods-downloader (>= 2.1, < 3.0)
34 | cocoapods-plugins (>= 1.0.0, < 2.0)
35 | cocoapods-search (>= 1.0.0, < 2.0)
36 | cocoapods-trunk (>= 1.6.0, < 2.0)
37 | cocoapods-try (>= 1.1.0, < 2.0)
38 | colored2 (~> 3.1)
39 | escape (~> 0.0.4)
40 | fourflusher (>= 2.3.0, < 3.0)
41 | gh_inspector (~> 1.0)
42 | molinillo (~> 0.8.0)
43 | nap (~> 1.0)
44 | ruby-macho (>= 2.3.0, < 3.0)
45 | xcodeproj (>= 1.23.0, < 2.0)
46 | cocoapods-core (1.15.2)
47 | activesupport (>= 5.0, < 8)
48 | addressable (~> 2.8)
49 | algoliasearch (~> 1.0)
50 | concurrent-ruby (~> 1.1)
51 | fuzzy_match (~> 2.0.4)
52 | nap (~> 1.0)
53 | netrc (~> 0.11)
54 | public_suffix (~> 4.0)
55 | typhoeus (~> 1.0)
56 | cocoapods-deintegrate (1.0.5)
57 | cocoapods-downloader (2.1)
58 | cocoapods-plugins (1.0.0)
59 | nap
60 | cocoapods-search (1.0.1)
61 | cocoapods-trunk (1.6.0)
62 | nap (>= 0.8, < 2.0)
63 | netrc (~> 0.11)
64 | cocoapods-try (1.2.0)
65 | colored2 (3.1.2)
66 | concurrent-ruby (1.3.4)
67 | connection_pool (2.4.1)
68 | drb (2.2.1)
69 | escape (0.0.4)
70 | ethon (0.16.0)
71 | ffi (>= 1.15.0)
72 | ffi (1.17.0)
73 | ffi (1.17.0-aarch64-linux-gnu)
74 | ffi (1.17.0-aarch64-linux-musl)
75 | ffi (1.17.0-arm-linux-gnu)
76 | ffi (1.17.0-arm-linux-musl)
77 | ffi (1.17.0-arm64-darwin)
78 | ffi (1.17.0-x86-linux-gnu)
79 | ffi (1.17.0-x86-linux-musl)
80 | ffi (1.17.0-x86_64-darwin)
81 | ffi (1.17.0-x86_64-linux-gnu)
82 | ffi (1.17.0-x86_64-linux-musl)
83 | fourflusher (2.3.1)
84 | fuzzy_match (2.0.4)
85 | gh_inspector (1.1.3)
86 | httpclient (2.8.3)
87 | i18n (1.14.6)
88 | concurrent-ruby (~> 1.0)
89 | json (2.7.2)
90 | logger (1.6.1)
91 | minitest (5.25.1)
92 | molinillo (0.8.0)
93 | nanaimo (0.3.0)
94 | nap (1.1.0)
95 | netrc (0.11.0)
96 | nkf (0.2.0)
97 | public_suffix (4.0.7)
98 | rexml (3.4.2)
99 | ruby-macho (2.5.1)
100 | securerandom (0.3.1)
101 | typhoeus (1.4.1)
102 | ethon (>= 0.9.0)
103 | tzinfo (2.0.6)
104 | concurrent-ruby (~> 1.0)
105 | xcodeproj (1.25.0)
106 | CFPropertyList (>= 2.3.3, < 4.0)
107 | atomos (~> 0.1.3)
108 | claide (>= 1.0.2, < 2.0)
109 | colored2 (~> 3.1)
110 | nanaimo (~> 0.3.0)
111 | rexml (>= 3.3.2, < 4.0)
112 |
113 | PLATFORMS
114 | aarch64-linux-gnu
115 | aarch64-linux-musl
116 | arm-linux-gnu
117 | arm-linux-musl
118 | arm64-darwin
119 | ruby
120 | x86-linux-gnu
121 | x86-linux-musl
122 | x86_64-darwin
123 | x86_64-linux-gnu
124 | x86_64-linux-musl
125 |
126 | DEPENDENCIES
127 | cocoapods (~> 1.15.0)
128 |
129 | BUNDLED WITH
130 | 2.5.20
131 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientArray+DecodingOutcome.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/19/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | extension Resilient {
7 |
8 | init(_ results: [Result]) where Value == [T] {
9 | self.init(results, transform: { $0 })
10 | }
11 |
12 | init(_ results: [Result]) where Value == [T]? {
13 | self.init(results, transform: { $0 })
14 | }
15 |
16 | /**
17 | - parameter transform: While the two lines above both say `{ $0 }` they are actually different because the first one is of type `([T]) -> [T]` and the second is of type `([T]) -> [T]?`.
18 | */
19 | private init(_ results: [Result], transform: ([T]) -> Value) {
20 | let elements = results.compactMap { try? $0.get() }
21 | let value = transform(elements)
22 | if elements.count == results.count {
23 | self.init(value, outcome: .decodedSuccessfully)
24 | } else {
25 | #if DEBUG
26 | let error = ResilientDecodingOutcome.ArrayDecodingError(results: results)
27 | /// `ArrayDecodingError` is not reported
28 | self.init(value, outcome: .recoveredFrom(error, wasReported: false))
29 | #else
30 | self.init(value, outcome: .recoveredFromDebugOnlyError)
31 | #endif
32 | }
33 | }
34 |
35 | }
36 |
37 | #if DEBUG
38 |
39 | extension ResilientDecodingOutcome {
40 |
41 | /**
42 | A type representing some number of errors encountered while decoding an array
43 | */
44 | public struct ArrayDecodingError: Error {
45 | public let results: [Result]
46 | public var errors: [Error] {
47 | results.compactMap { result in
48 | switch result {
49 | case .success:
50 | return nil
51 | case .failure(let error):
52 | return error
53 | }
54 | }
55 | }
56 | /// `ArrayDecodingError` should only be initialized in this file
57 | fileprivate init(results: [Result]) {
58 | self.results = results
59 | }
60 | }
61 |
62 | /**
63 | Creates an `ArrayDecodingError` representation of this outcome.
64 | */
65 | fileprivate func arrayDecodingError() -> ResilientDecodingOutcome.ArrayDecodingError {
66 | typealias ArrayDecodingError = ResilientDecodingOutcome.ArrayDecodingError
67 | switch self {
68 | case .decodedSuccessfully, .keyNotFound, .valueWasNil:
69 | return .init(results: [])
70 | case let .recoveredFrom(error as ArrayDecodingError, wasReported):
71 | /// `ArrayDecodingError` should not be reported
72 | assert(!wasReported)
73 | return error
74 | case .recoveredFrom(let error, _):
75 | /// When recovering from a top level error, we can provide the error value in the array, instead of returning an empty array. We believe this is a win for usability.
76 | return .init(results: [.failure(error)])
77 | }
78 | }
79 |
80 | }
81 |
82 | extension Resilient.ProjectedValue {
83 |
84 | /**
85 | This subscript adds the `errors` and `results` property to `Resilient<[T]>` values using `dynamicMemberLookup`.
86 | */
87 | public subscript(
88 | dynamicMember keyPath: KeyPath, U>) -> U
89 | where Value == [T]
90 | {
91 | outcome.arrayDecodingError()[keyPath: keyPath]
92 | }
93 |
94 | /**
95 | This subscript adds the `errors` and `results` property to `Resilient<[T]>` values using `dynamicMemberLookup`.
96 | */
97 | public subscript(
98 | dynamicMember keyPath: KeyPath, U>) -> U
99 | where Value == [T]?
100 | {
101 | outcome.arrayDecodingError()[keyPath: keyPath]
102 | }
103 |
104 | }
105 |
106 | #endif
107 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientArray.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | // MARK: - Decoding
7 |
8 | /**
9 | Synthesized `Decodable` initializers are effectively equivalent to writing the following initializer:
10 | ```
11 | init(from decoder: Decoder) throws {
12 | let container = try decoder.container(keyedBy: SynthesizedCodingKeys.self)
13 | self.propertyA = try container.decode(TypeOfPropertyA.self, forKey: .propertyA)
14 | self.propertyB = try container.decode(TypeOfPropertyB.self, forKey: .propertyB)
15 | …and so on
16 | }
17 | ```
18 | By declaring these `public` methods here, if `TypeOfPropertyA` is a specialization of `Resilient` such that it matches one of the following method signatures, Swift will call that overload of the `decode(_:forKey)` method instead of the default implementation provided by `Foundation`. This allows us to perform custom logic to _resiliently_ recover from decoding errors.
19 | */
20 | extension KeyedDecodingContainer {
21 |
22 | /**
23 | Decodes a `Resilient` array, omitting elements as errors are encountered.
24 | */
25 | public func decode(_ type: Resilient<[Element]>.Type, forKey key: Key) throws -> Resilient<[Element]>
26 | where
27 | Element: Decodable
28 | {
29 | resilientlyDecode(valueForKey: key, fallback: []) { $0.resilientlyDecodeArray() }
30 | }
31 |
32 | /**
33 | Decodes an optional `Resilient` array. A missing key or `nil` value will silently set the property to `nil`.
34 | */
35 | public func decode(_ type: Resilient<[Element]?>.Type, forKey key: Key) throws -> Resilient<[Element]?> {
36 | resilientlyDecode(valueForKey: key, fallback: nil) { $0.resilientlyDecodeArray().map { $0 } }
37 | }
38 |
39 | }
40 |
41 | extension Decoder {
42 |
43 | func resilientlyDecodeArray() -> Resilient<[Element]>
44 | {
45 | resilientlyDecodeArray(of: Element.self, transform: { $0 })
46 | }
47 |
48 | /**
49 | We can't just use `map` because the transform needs to happen _before_ we wrap the value in `Resilient` so that that the element type of `ArrayDecodingError` is correct.
50 | */
51 | func resilientlyDecodeArray(
52 | of intermediateElementType: IntermediateElement.Type,
53 | transform: (IntermediateElement) -> Element) -> Resilient<[Element]>
54 | {
55 | do {
56 | var container = try unkeyedContainer()
57 | var results: [Result] = []
58 | if let count = container.count {
59 | results.reserveCapacity(count)
60 | }
61 | while !container.isAtEnd {
62 | /// It is very unlikely that an error will be thrown here, so it is fine that this would fail the entire array
63 | let elementDecoder = try container.superDecoder()
64 | do {
65 | results.append(.success(transform(try elementDecoder.singleValueContainer().decode(IntermediateElement.self))))
66 | } catch {
67 | elementDecoder.reportError(error)
68 | results.append(.failure(error))
69 | }
70 | }
71 | return Resilient(results)
72 | } catch {
73 | reportError(error)
74 | return Resilient([], outcome: .recoveredFrom(error, wasReported: true))
75 | }
76 | }
77 |
78 | }
79 |
80 | // MARK: - Catch Common Mistakes
81 |
82 | /**
83 | For the following cases, the user probably meant to use `[T]` as the property type.
84 | */
85 | extension KeyedDecodingContainer {
86 | public func decode(_ type: Resilient<[T?]>.Type, forKey key: Key) throws -> Resilient<[T?]> {
87 | assertionFailure()
88 | return try decode(Resilient<[T]>.self, forKey: key).map { $0 }
89 | }
90 | public func decode(_ type: Resilient<[T?]?>.Type, forKey key: Key) throws -> Resilient<[T?]?> {
91 | assertionFailure()
92 | return try decode(Resilient<[T]>.self, forKey: key).map { $0 }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientDictionary.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/23/20.
2 | // Copyright © 2020 Airbnb Inc. All rights reserved.
3 |
4 | import Foundation
5 |
6 | /**
7 | Synthesized `Decodable` initializers are effectively equivalent to writing the following initializer:
8 | ```
9 | init(from decoder: Decoder) throws {
10 | let container = try decoder.container(keyedBy: SynthesizedCodingKeys.self)
11 | self.propertyA = try container.decode(TypeOfPropertyA.self, forKey: .propertyA)
12 | self.propertyB = try container.decode(TypeOfPropertyB.self, forKey: .propertyB)
13 | …and so on
14 | }
15 | ```
16 | By declaring these `public` methods here, if `TypeOfPropertyA` is a specialization of `Resilient` such that it matches one of the following method signatures, Swift will call that overload of the `decode(_:forKey)` method instead of the default implementation provided by `Foundation`. This allows us to perform custom logic to _resiliently_ recover from decoding errors.
17 | */
18 | extension KeyedDecodingContainer {
19 |
20 | /**
21 | Decodes a `Resilient` dictionary, omitting values as errors are encountered.
22 | */
23 | public func decode(_ type: Resilient<[String: Value]>.Type, forKey key: Key) throws -> Resilient<[String: Value]>
24 | {
25 | resilientlyDecode(valueForKey: key, fallback: [:]) { $0.resilientlyDecodeDictionary() }
26 | }
27 |
28 | /**
29 | Decodes an optional `Resilient` dictionary. If the field is missing or the value is `nil` the decoded property will also be `nil`.
30 | */
31 | public func decode(_ type: Resilient<[String: Value]?>.Type, forKey key: Key) throws -> Resilient<[String: Value]?> {
32 | resilientlyDecode(valueForKey: key, fallback: nil) { $0.resilientlyDecodeDictionary().map { $0 } }
33 | }
34 |
35 | }
36 |
37 | extension Decoder {
38 |
39 | func resilientlyDecodeDictionary() -> Resilient<[String: Value]>
40 | {
41 | resilientlyDecodeDictionary(of: Value.self, transform: { $0 })
42 | }
43 |
44 | /**
45 | We can't just use `map` because the transform needs to happen _before_ we wrap the value in `Resilient` so that that the value type of `DictionaryDecodingError` is correct.
46 | */
47 | func resilientlyDecodeDictionary(
48 | of intermediateValueType: IntermediateValue.Type,
49 | transform: (IntermediateValue) -> Value) -> Resilient<[String: Value]>
50 | {
51 | do {
52 | let value = try singleValueContainer()
53 | .decode([String: DecodingResultContainer].self)
54 | .mapValues { $0.result.map(transform) }
55 | return Resilient(value)
56 | } catch {
57 | reportError(error)
58 | return Resilient([:], outcome: .recoveredFrom(error, wasReported: true))
59 | }
60 | }
61 |
62 | }
63 |
64 | // MARK: - Private
65 |
66 | /**
67 | We can't use `KeyedDecodingContainer` to decode a dictionary because it will use `keyDecodingStrategy` to map the keys, which dictionary values do not.
68 | */
69 | private struct DecodingResultContainer: Decodable {
70 | let result: Result
71 | init(from decoder: Decoder) throws {
72 | result = Result {
73 | do {
74 | return try decoder.singleValueContainer().decode(Success.self)
75 | } catch {
76 | decoder.reportError(error)
77 | throw error
78 | }
79 | }
80 | }
81 | }
82 |
83 | // MARK: - Catch Common Mistakes
84 |
85 | /**
86 | For the following cases, the user probably meant to use `[String: T]` as the property type.
87 | */
88 | extension KeyedDecodingContainer {
89 | public func decode(_ type: Resilient<[String: T?]>.Type, forKey key: Key) throws -> Resilient<[T?]> {
90 | assertionFailure()
91 | return try decode(Resilient<[T]>.self, forKey: key).map { $0 }
92 | }
93 | public func decode(_ type: Resilient<[String: T?]?>.Type, forKey key: Key) throws -> Resilient<[T?]?> {
94 | assertionFailure()
95 | return try decode(Resilient<[T]>.self, forKey: key).map { $0 }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientArrayTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | private struct ResilientArrayWrapper: Decodable {
8 | @Resilient var resilientArray: [Int]
9 | @Resilient var optionalResilientArray: [Int]?
10 | }
11 |
12 | final class ResilientArrayTests: XCTestCase {
13 |
14 | func testDecodesValidInputWithoutErrors() throws {
15 | let mock = try decodeMock(ResilientArrayWrapper.self, """
16 | {
17 | "resilientArray": [1, 2, 3],
18 | "optionalResilientArray": [4, 5, 6],
19 | }
20 | """)
21 | XCTAssertEqual(mock.resilientArray, [1, 2, 3])
22 | XCTAssertEqual(mock.optionalResilientArray, [4, 5, 6])
23 | #if DEBUG
24 | XCTAssert(mock.$resilientArray.outcome.is(.decodedSuccessfully))
25 | XCTAssert(mock.$optionalResilientArray.outcome.is(.decodedSuccessfully))
26 | XCTAssert(mock.$resilientArray.errors.isEmpty)
27 | XCTAssert(mock.$optionalResilientArray.errors.isEmpty)
28 | #endif
29 | }
30 |
31 | func testDecodesWhenMissingKeys() throws {
32 | let mock = try decodeMock(ResilientArrayWrapper.self, """
33 | {
34 | }
35 | """)
36 | XCTAssertEqual(mock.resilientArray, [])
37 | XCTAssertNil(mock.optionalResilientArray)
38 | #if DEBUG
39 | XCTAssert(mock.$resilientArray.outcome.is(.keyNotFound))
40 | XCTAssert(mock.$optionalResilientArray.outcome.is(.keyNotFound))
41 | XCTAssertEqual(mock.$resilientArray.errors.count, 0)
42 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 0)
43 | #endif
44 | }
45 |
46 | func testDecodesNullValue() throws {
47 | let mock = try decodeMock(ResilientArrayWrapper.self, """
48 | {
49 | "resilientArray": null,
50 | "optionalResilientArray": null,
51 | }
52 | """)
53 | XCTAssertEqual(mock.resilientArray, [])
54 | XCTAssertNil(mock.optionalResilientArray)
55 | #if DEBUG
56 | XCTAssert(mock.$resilientArray.outcome.is(.valueWasNil))
57 | XCTAssert(mock.$optionalResilientArray.outcome.is(.valueWasNil))
58 | XCTAssertEqual(mock.$resilientArray.errors.count, 0)
59 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 0)
60 | #endif
61 | }
62 |
63 | func testResilientlyDecodesIncorrectType() throws {
64 | let mock = try decodeMock(ResilientArrayWrapper.self, """
65 | {
66 | "resilientArray": 1,
67 | "optionalResilientArray": 1,
68 | }
69 | """,
70 | expectedErrorCount: 2)
71 | XCTAssertEqual(mock.resilientArray, [])
72 | XCTAssertEqual(mock.optionalResilientArray, [])
73 | #if DEBUG
74 | XCTAssert(mock.$resilientArray.outcome.is(.recoveredFromError(wasReported: true)))
75 | XCTAssertEqual(mock.$resilientArray.errors.count, 1)
76 | XCTAssertEqual(mock.$resilientArray.results.map { try? $0.get() }, [nil])
77 | XCTAssert(mock.$optionalResilientArray.outcome.is(.recoveredFromError(wasReported: true)))
78 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 1)
79 | XCTAssertEqual(mock.$optionalResilientArray.results.map { try? $0.get() }, [nil])
80 | #endif
81 | }
82 |
83 | func testResilientlyDecodesArrayWithInvalidElements() throws {
84 | let mock = try decodeMock(ResilientArrayWrapper.self, """
85 | {
86 | "resilientArray": [1, "2", 3, "4", 5],
87 | "optionalResilientArray": ["1", 2, "3", "4", 5],
88 | }
89 | """,
90 | expectedErrorCount: 5)
91 | XCTAssertEqual(mock.resilientArray, [1, 3, 5])
92 | XCTAssertEqual(mock.optionalResilientArray, [2, 5])
93 | #if DEBUG
94 | XCTAssert(mock.$resilientArray.outcome.is(.recoveredFromError(wasReported: false)))
95 | XCTAssertEqual(mock.$resilientArray.errors.count, 2)
96 | XCTAssertEqual(mock.$resilientArray.results.map { try? $0.get() }, [1, nil, 3, nil, 5])
97 | XCTAssert(mock.$optionalResilientArray.outcome.is(.recoveredFromError(wasReported: false)))
98 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 3)
99 | XCTAssertEqual(mock.$optionalResilientArray.results.map { try? $0.get() }, [nil, 2, nil, nil, 5])
100 | #endif
101 | }
102 |
103 | }
104 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientDictionary+DecodingOutcome.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/23/20.
2 | // Copyright © 2020 Airbnb Inc. All rights reserved.
3 |
4 | import Foundation
5 |
6 | extension Resilient {
7 |
8 | init(_ results: [String: Result]) where Value == [String: T] {
9 | self.init(results, transform: { $0 })
10 | }
11 |
12 | init(_ results: [String: Result]) where Value == [String: T]? {
13 | self.init(results, transform: { $0 })
14 | }
15 |
16 | /**
17 | - parameter transform: While the two lines above both say `{ $0 }` they are actually different because the first one is of type `([String: T]) -> [String: T]` and the second is of type `([String: T]) -> [String: T]?`.
18 | */
19 | private init(_ results: [String: Result], transform: ([String: T]) -> Value) {
20 | let dictionary = results.compactMapValues { try? $0.get() }
21 | let value = transform(dictionary)
22 | if dictionary.count == results.count {
23 | self.init(value, outcome: .decodedSuccessfully)
24 | } else {
25 | #if DEBUG
26 | let error = ResilientDecodingOutcome.DictionaryDecodingError(results: results)
27 | /// `DictionaryDecodingError` is not reported
28 | self.init(value, outcome: .recoveredFrom(error, wasReported: false))
29 | #else
30 | self.init(value, outcome: .recoveredFromDebugOnlyError)
31 | #endif
32 | }
33 | }
34 |
35 | }
36 |
37 | #if DEBUG
38 |
39 | extension ResilientDecodingOutcome {
40 |
41 | /**
42 | A type representing some number of errors encountered while decoding a dictionary
43 | */
44 | public struct DictionaryDecodingError: Error {
45 | public let results: [String: Result]
46 | public var errors: [Error] {
47 | /// It is currently impossible to have both a `topLevelError` and `results` at the same time, but this code is simpler than having an `enum` nested in this type.
48 | [topLevelError].compactMap { $0 } + results.compactMap { pair in
49 | switch pair.value {
50 | case .success:
51 | return nil
52 | case .failure(let error):
53 | return error
54 | }
55 | }
56 | }
57 |
58 | /**
59 | Since we don't include the top level error in `results`, we have to store it separately.
60 | */
61 | private var topLevelError: Error?
62 |
63 | /// `DictionaryDecodingError` should only be initialized in this file
64 | fileprivate init(results: [String: Result]) {
65 | self.topLevelError = nil
66 | self.results = results
67 | }
68 |
69 | /// `DictionaryDecodingError` should only be initialized in this file
70 | fileprivate init(topLevelError: Error?) {
71 | self.topLevelError = topLevelError
72 | self.results = [:]
73 | }
74 | }
75 |
76 | /**
77 | Creates a `DictionaryDecodingError` representation of this outcome.
78 | */
79 | fileprivate func dictionaryDecodingError() -> ResilientDecodingOutcome.DictionaryDecodingError {
80 | typealias DictionaryDecodingError = ResilientDecodingOutcome.DictionaryDecodingError
81 | switch self {
82 | case .decodedSuccessfully, .keyNotFound, .valueWasNil:
83 | return .init(results: [:])
84 | case let .recoveredFrom(error as DictionaryDecodingError, wasReported):
85 | /// `DictionaryDecodingError` should not be reported
86 | assert(!wasReported)
87 | return error
88 | case .recoveredFrom(let error, _):
89 | /// Unlike array, we chose not to provide the top level error in the dictionary since there isn't a good way to choose an appropriate key.
90 | return .init(topLevelError: error)
91 | }
92 | }
93 |
94 | }
95 |
96 | extension Resilient.ProjectedValue {
97 |
98 | /**
99 | This subscript adds the `errors` and `results` property to `Resilient<[String: T]>` values using `dynamicMemberLookup`.
100 | */
101 | public subscript(
102 | dynamicMember keyPath: KeyPath, U>) -> U
103 | where Value == [String: T]
104 | {
105 | outcome.dictionaryDecodingError()[keyPath: keyPath]
106 | }
107 |
108 | /**
109 | This subscript adds the `errors` and `results` property to `Resilient<[String: T]?>` values using `dynamicMemberLookup`.
110 | */
111 | public subscript(
112 | dynamicMember keyPath: KeyPath, U>) -> U
113 | where Value == [String: T]?
114 | {
115 | outcome.dictionaryDecodingError()[keyPath: keyPath]
116 | }
117 |
118 | }
119 |
120 | #endif
121 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientDictionaryTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/23/20.
2 | // Copyright © 2020 Airbnb Inc. All rights reserved.
3 |
4 | import Foundation
5 |
6 | #if DEBUG
7 | @testable import ResilientDecoding
8 | #else
9 | import ResilientDecoding
10 | #endif
11 | import XCTest
12 |
13 | private struct ResilientDictionaryWrapper: Decodable {
14 | @Resilient var resilientDictionary: [String: Int]
15 | @Resilient var optionalResilientDictionary: [String: Int]?
16 | }
17 |
18 | final class DictionaryTests: XCTestCase {
19 |
20 | func testDecodesValidInputWithoutErrors() throws {
21 | let mock = try decodeMock(ResilientDictionaryWrapper.self, """
22 | {
23 | "resilientDictionary": {
24 | "1": 1,
25 | "2": 2,
26 | "3": 3,
27 | },
28 | "optionalResilientDictionary": {
29 | "4": 4,
30 | "5": 5,
31 | "6": 6,
32 | },
33 | }
34 | """)
35 | XCTAssertEqual(mock.resilientDictionary, ["1": 1, "2": 2, "3": 3])
36 | XCTAssertEqual(mock.optionalResilientDictionary, ["4": 4, "5": 5, "6": 6])
37 | #if DEBUG
38 | XCTAssert(mock.$resilientDictionary.outcome.is(.decodedSuccessfully))
39 | XCTAssert(mock.$optionalResilientDictionary.outcome.is(.decodedSuccessfully))
40 | XCTAssert(mock.$resilientDictionary.errors.isEmpty)
41 | XCTAssert(mock.$optionalResilientDictionary.errors.isEmpty)
42 | #endif
43 | }
44 |
45 | func testDecodesWhenMissingKeys() throws {
46 | let mock = try decodeMock(ResilientDictionaryWrapper.self, """
47 | {
48 | }
49 | """)
50 | XCTAssertEqual(mock.resilientDictionary, [:])
51 | XCTAssertNil(mock.optionalResilientDictionary)
52 | #if DEBUG
53 | XCTAssert(mock.$resilientDictionary.outcome.is(.keyNotFound))
54 | XCTAssert(mock.$optionalResilientDictionary.outcome.is(.keyNotFound))
55 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 0)
56 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 0)
57 | #endif
58 | }
59 |
60 | func testDecodesNullValue() throws {
61 | let mock = try decodeMock(ResilientDictionaryWrapper.self, """
62 | {
63 | "resilientDictionary": null,
64 | "optionalResilientDictionary": null,
65 | }
66 | """)
67 | XCTAssertEqual(mock.resilientDictionary, [:])
68 | XCTAssertNil(mock.optionalResilientDictionary)
69 | #if DEBUG
70 | XCTAssert(mock.$resilientDictionary.outcome.is(.valueWasNil))
71 | XCTAssert(mock.$optionalResilientDictionary.outcome.is(.valueWasNil))
72 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 0)
73 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 0)
74 | #endif
75 | }
76 |
77 | func testResilientlyDecodesIncorrectType() throws {
78 | let mock = try decodeMock(ResilientDictionaryWrapper.self, """
79 | {
80 | "resilientDictionary": 1,
81 | "optionalResilientDictionary": 1,
82 | }
83 | """,
84 | expectedErrorCount: 2)
85 | XCTAssertEqual(mock.resilientDictionary, [:])
86 | XCTAssertEqual(mock.optionalResilientDictionary, [:])
87 | #if DEBUG
88 | XCTAssert(mock.$resilientDictionary.outcome.is(.recoveredFromError(wasReported: true)))
89 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 1)
90 | XCTAssert(mock.$optionalResilientDictionary.outcome.is(.recoveredFromError(wasReported: true)))
91 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 1)
92 | #endif
93 | }
94 |
95 | func testResilientlyDecodesArrayWithInvalidElements() throws {
96 | let mock = try decodeMock(ResilientDictionaryWrapper.self, """
97 | {
98 | "resilientDictionary": {
99 | "1": 1,
100 | "2": "2",
101 | "3": 3,
102 | "4": "4",
103 | "5": 5,
104 | },
105 | "optionalResilientDictionary": {
106 | "1": "1",
107 | "2": 2,
108 | "3": "3",
109 | "4": "4",
110 | "5": 5,
111 | },
112 | }
113 | """,
114 | expectedErrorCount: 5)
115 | XCTAssertEqual(mock.resilientDictionary, ["1": 1, "3": 3, "5": 5])
116 | XCTAssertEqual(mock.optionalResilientDictionary, ["2": 2, "5": 5])
117 | #if DEBUG
118 | XCTAssert(mock.$resilientDictionary.outcome.is(.recoveredFromError(wasReported: false)))
119 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 2)
120 | XCTAssertEqual(mock.$resilientDictionary.results.mapValues { try? $0.get() }, ["1": 1, "2": nil, "3": 3, "4": nil, "5": 5])
121 | XCTAssert(mock.$optionalResilientDictionary.outcome.is(.recoveredFromError(wasReported: false)))
122 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 3)
123 | XCTAssertEqual(mock.$optionalResilientDictionary.results.mapValues { try? $0.get() }, ["1": nil, "2": 2, "3": nil, "4": nil, "5": 5])
124 | #endif
125 | }
126 |
127 | }
128 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientRawRepresentableArrayTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/4/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | private struct ResilientRawRepresentableArrayWrapper: Decodable {
8 | @Resilient var resilientArray: [ResilientEnum]
9 | @Resilient var optionalResilientArray: [ResilientEnum]?
10 | @Resilient var resilientArrayOfFrozenType: [ResilientFrozenEnum]
11 | @Resilient var optionalResilientArrayOfFrozenType: [ResilientFrozenEnum]?
12 | }
13 |
14 | final class ResilientRawRepresentableArrayTests: XCTestCase {
15 |
16 | func testDecodesValidInputWithoutErrors() throws {
17 | let mock = try decodeMock(ResilientRawRepresentableArrayWrapper.self, """
18 | {
19 | "resilientArray": ["existing", "existing"],
20 | "optionalResilientArray": ["existing", "existing"],
21 | "resilientArrayOfFrozenType": ["existing", "existing"],
22 | "optionalResilientArrayOfFrozenType": ["existing", "existing"],
23 | }
24 | """)
25 | XCTAssertEqual(mock.resilientArray, [.existing, .existing])
26 | XCTAssertEqual(mock.optionalResilientArray, [.existing, .existing])
27 | XCTAssertEqual(mock.resilientArrayOfFrozenType, [.existing, .existing])
28 | XCTAssertEqual(mock.optionalResilientArrayOfFrozenType, [.existing, .existing])
29 | #if DEBUG
30 | XCTAssertEqual(mock.$resilientArray.errors.count, 0)
31 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 0)
32 | XCTAssertEqual(mock.$resilientArrayOfFrozenType.errors.count, 0)
33 | XCTAssertEqual(mock.$optionalResilientArrayOfFrozenType.errors.count, 0)
34 | #endif
35 | }
36 |
37 | /**
38 | - note: We keep the non-optional properties in the JSON so they do not report errors
39 | */
40 | func testDecodesWhenMissingKeysWithoutErrors() throws {
41 | let mock = try decodeMock(ResilientRawRepresentableArrayWrapper.self, """
42 | {
43 | "resilientArray": ["existing", "existing"],
44 | "resilientArrayOfFrozenType": ["existing", "existing"],
45 | }
46 | """)
47 | XCTAssertEqual(mock.optionalResilientArray, nil)
48 | XCTAssertEqual(mock.optionalResilientArrayOfFrozenType, nil)
49 | #if DEBUG
50 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 0)
51 | XCTAssertEqual(mock.$optionalResilientArrayOfFrozenType.errors.count, 0)
52 | #endif
53 | }
54 |
55 | /**
56 | - note: We keep the non-optional properties in the JSON so they do not report errors
57 | */
58 | func testDecodesNullValuesWithoutErrors() throws {
59 | let mock = try decodeMock(ResilientRawRepresentableArrayWrapper.self, """
60 | {
61 | "resilientArray": ["existing", "existing"],
62 | "optionalResilientArray": null,
63 | "resilientArrayOfFrozenType": ["existing", "existing"],
64 | "optionalResilientArrayOfFrozenType": null,
65 | }
66 | """)
67 | XCTAssertEqual(mock.optionalResilientArray, nil)
68 | XCTAssertEqual(mock.optionalResilientArrayOfFrozenType, nil)
69 | #if DEBUG
70 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 0)
71 | XCTAssertEqual(mock.$optionalResilientArrayOfFrozenType.errors.count, 0)
72 | #endif
73 | }
74 |
75 | func testResilientlyDecodesNovelCases() throws {
76 | let mock = try decodeMock(ResilientRawRepresentableArrayWrapper.self, """
77 | {
78 | "resilientArray": ["existing", "novel", "existing"],
79 | "optionalResilientArray": ["novel", "existing", "novel"],
80 | "resilientArrayOfFrozenType": ["existing", "novel", "existing"],
81 | "optionalResilientArrayOfFrozenType": ["novel", "existing", "novel"],
82 | }
83 | """,
84 | expectedErrorCount: 3)
85 | XCTAssertEqual(mock.resilientArray, [.existing, .existing])
86 | XCTAssertEqual(mock.optionalResilientArray, [.existing])
87 | XCTAssertEqual(mock.resilientArrayOfFrozenType, [.existing, .existing])
88 | XCTAssertEqual(mock.optionalResilientArrayOfFrozenType, [.existing])
89 |
90 | #if DEBUG
91 | /// All properties provide errors for inspection, but only _frozen_ types report the error (hence "3" expected errors above)
92 | XCTAssertEqual(mock.$resilientArray.errors.count, 1)
93 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 2)
94 | XCTAssertEqual(mock.$resilientArrayOfFrozenType.errors.count, 1)
95 | XCTAssertEqual(mock.$optionalResilientArrayOfFrozenType.errors.count, 2)
96 | #endif
97 | }
98 |
99 | func testResilientlyDecodesInvalidCases() throws {
100 | let mock = try decodeMock(ResilientRawRepresentableArrayWrapper.self, """
101 | {
102 | "resilientArray": ["existing", 1, "existing"],
103 | "optionalResilientArray": [2, "existing", 3],
104 | "resilientArrayOfFrozenType": ["existing", 4, "existing"],
105 | "optionalResilientArrayOfFrozenType": [5, "existing", 6],
106 | }
107 | """,
108 | expectedErrorCount: 6)
109 | XCTAssertEqual(mock.resilientArray, [.existing, .existing])
110 | XCTAssertEqual(mock.optionalResilientArray, [.existing])
111 | XCTAssertEqual(mock.resilientArrayOfFrozenType, [.existing, .existing])
112 | XCTAssertEqual(mock.optionalResilientArrayOfFrozenType, [.existing])
113 |
114 | #if DEBUG
115 | /// All properties provide errors for inspection, but only _frozen_ types report the error (hence "6" expected errors above)
116 | XCTAssertEqual(mock.$resilientArray.errors.count, 1)
117 | XCTAssertEqual(mock.$optionalResilientArray.errors.count, 2)
118 | XCTAssertEqual(mock.$resilientArrayOfFrozenType.errors.count, 1)
119 | XCTAssertEqual(mock.$optionalResilientArrayOfFrozenType.errors.count, 2)
120 | #endif
121 | }
122 |
123 | }
124 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | #if !canImport(ObjectiveC)
2 | import XCTest
3 |
4 | extension BugTests {
5 | // DO NOT MODIFY: This is autogenerated, use:
6 | // `swift test --generate-linuxmain`
7 | // to regenerate.
8 | static let __allTests__BugTests = [
9 | ("testResilientURLsDecodeSuccessfully", testResilientURLsDecodeSuccessfully),
10 | ]
11 | }
12 |
13 | extension DictionaryTests {
14 | // DO NOT MODIFY: This is autogenerated, use:
15 | // `swift test --generate-linuxmain`
16 | // to regenerate.
17 | static let __allTests__DictionaryTests = [
18 | ("testDecodesNullValue", testDecodesNullValue),
19 | ("testDecodesValidInputWithoutErrors", testDecodesValidInputWithoutErrors),
20 | ("testDecodesWhenMissingKeys", testDecodesWhenMissingKeys),
21 | ("testResilientlyDecodesArrayWithInvalidElements", testResilientlyDecodesArrayWithInvalidElements),
22 | ("testResilientlyDecodesIncorrectType", testResilientlyDecodesIncorrectType),
23 | ]
24 | }
25 |
26 | extension MemoryTests {
27 | // DO NOT MODIFY: This is autogenerated, use:
28 | // `swift test --generate-linuxmain`
29 | // to regenerate.
30 | static let __allTests__MemoryTests = [
31 | ("testNoOverheadInRelease", testNoOverheadInRelease),
32 | ]
33 | }
34 |
35 | extension ResilientArrayTests {
36 | // DO NOT MODIFY: This is autogenerated, use:
37 | // `swift test --generate-linuxmain`
38 | // to regenerate.
39 | static let __allTests__ResilientArrayTests = [
40 | ("testDecodesNullValue", testDecodesNullValue),
41 | ("testDecodesValidInputWithoutErrors", testDecodesValidInputWithoutErrors),
42 | ("testDecodesWhenMissingKeys", testDecodesWhenMissingKeys),
43 | ("testResilientlyDecodesArrayWithInvalidElements", testResilientlyDecodesArrayWithInvalidElements),
44 | ("testResilientlyDecodesIncorrectType", testResilientlyDecodesIncorrectType),
45 | ]
46 | }
47 |
48 | extension ResilientDecodingErrorReporterTests {
49 | // DO NOT MODIFY: This is autogenerated, use:
50 | // `swift test --generate-linuxmain`
51 | // to regenerate.
52 | static let __allTests__ResilientDecodingErrorReporterTests = [
53 | ("testDebugDescription", testDebugDescription),
54 | ]
55 | }
56 |
57 | extension ResilientOptionalTests {
58 | // DO NOT MODIFY: This is autogenerated, use:
59 | // `swift test --generate-linuxmain`
60 | // to regenerate.
61 | static let __allTests__ResilientOptionalTests = [
62 | ("testDecodesNullValueWithoutErrors", testDecodesNullValueWithoutErrors),
63 | ("testDecodesValidInputWithoutErrors", testDecodesValidInputWithoutErrors),
64 | ("testDecodesWhenMissingKeyWithoutErrors", testDecodesWhenMissingKeyWithoutErrors),
65 | ("testResilientlyDecodesInvalidValue", testResilientlyDecodesInvalidValue),
66 | ]
67 | }
68 |
69 | extension ResilientRawRepresentableArrayTests {
70 | // DO NOT MODIFY: This is autogenerated, use:
71 | // `swift test --generate-linuxmain`
72 | // to regenerate.
73 | static let __allTests__ResilientRawRepresentableArrayTests = [
74 | ("testDecodesNullValuesWithoutErrors", testDecodesNullValuesWithoutErrors),
75 | ("testDecodesValidInputWithoutErrors", testDecodesValidInputWithoutErrors),
76 | ("testDecodesWhenMissingKeysWithoutErrors", testDecodesWhenMissingKeysWithoutErrors),
77 | ("testResilientlyDecodesInvalidCases", testResilientlyDecodesInvalidCases),
78 | ("testResilientlyDecodesNovelCases", testResilientlyDecodesNovelCases),
79 | ]
80 | }
81 |
82 | extension ResilientRawRepresentableDictionaryTests {
83 | // DO NOT MODIFY: This is autogenerated, use:
84 | // `swift test --generate-linuxmain`
85 | // to regenerate.
86 | static let __allTests__ResilientRawRepresentableDictionaryTests = [
87 | ("testDecodesNullValuesWithoutErrors", testDecodesNullValuesWithoutErrors),
88 | ("testDecodesValidInputWithoutErrors", testDecodesValidInputWithoutErrors),
89 | ("testDecodesWhenMissingKeysWithoutErrors", testDecodesWhenMissingKeysWithoutErrors),
90 | ("testKeyDecodingStrategyIsIgnored", testKeyDecodingStrategyIsIgnored),
91 | ("testResilientlyDecodesInvalidCases", testResilientlyDecodesInvalidCases),
92 | ("testResilientlyDecodesNovelCases", testResilientlyDecodesNovelCases),
93 | ]
94 | }
95 |
96 | extension ResilientRawRepresentableEnumTests {
97 | // DO NOT MODIFY: This is autogenerated, use:
98 | // `swift test --generate-linuxmain`
99 | // to regenerate.
100 | static let __allTests__ResilientRawRepresentableEnumTests = [
101 | ("testDecodesMissingOptionalValuesWithoutErrors", testDecodesMissingOptionalValuesWithoutErrors),
102 | ("testDecodesNullOptionalValuesWithoutErrors", testDecodesNullOptionalValuesWithoutErrors),
103 | ("testDecodesValidCasesWithoutErrors", testDecodesValidCasesWithoutErrors),
104 | ("testResilientlyDecodesInvalidCases", testResilientlyDecodesInvalidCases),
105 | ("testResilientlyDecodesMissingValues", testResilientlyDecodesMissingValues),
106 | ("testResilientlyDecodesNovelCases", testResilientlyDecodesNovelCases),
107 | ]
108 | }
109 |
110 | public func __allTests() -> [XCTestCaseEntry] {
111 | return [
112 | testCase(BugTests.__allTests__BugTests),
113 | testCase(DictionaryTests.__allTests__DictionaryTests),
114 | testCase(MemoryTests.__allTests__MemoryTests),
115 | testCase(ResilientArrayTests.__allTests__ResilientArrayTests),
116 | testCase(ResilientDecodingErrorReporterTests.__allTests__ResilientDecodingErrorReporterTests),
117 | testCase(ResilientOptionalTests.__allTests__ResilientOptionalTests),
118 | testCase(ResilientRawRepresentableArrayTests.__allTests__ResilientRawRepresentableArrayTests),
119 | testCase(ResilientRawRepresentableDictionaryTests.__allTests__ResilientRawRepresentableDictionaryTests),
120 | testCase(ResilientRawRepresentableEnumTests.__allTests__ResilientRawRepresentableEnumTests),
121 | ]
122 | }
123 | #endif
124 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/Resilient.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/24/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | // MARK: - Resilient
7 |
8 | @propertyWrapper
9 | public struct Resilient: Decodable {
10 |
11 | /**
12 | If this initializer is called it is likely because a property was marked as `Resilient` despite the underlying type not supporting resilient decoding. For instance, a developer may write `@Resilient var numberOfThings: Int`, but since `Int` doesn't provide a mechanism for recovering from a decoding failure (like `Array`s and `Optional`s do) wrapping the property in `Resilient` does nothing.
13 | If this happens in production, we decode the wrapped value the same way we would if it wasn't `Resilient`, and if an error is thrown it proceeds up the stack uncaught by the mechanisms of `Resilient`. Since it is unlikely that this is what the developer intended, we `assert` in debug to give the developer a chance to fix their mistake, potentially rewriting the example above as `@Resilient var numberOfThings: Int?` (which would catch decoding errors and set `numberOfThings` to `nil`.
14 | */
15 | public init(from decoder: Decoder) throws {
16 | assertionFailure()
17 | let value = try decoder.singleValueContainer().decode(Value.self)
18 | self = Self(value, outcome: .decodedSuccessfully)
19 | }
20 |
21 | /**
22 | Initialized a `Resilient` value as though it had been decoded without encountering any errors.
23 | */
24 | public init(_ value: Value) {
25 | self.wrappedValue = value
26 | self.outcome = .decodedSuccessfully
27 | }
28 |
29 | init(_ value: Value, outcome: ResilientDecodingOutcome) {
30 | self.wrappedValue = value
31 | self.outcome = outcome
32 | }
33 |
34 | public init(wrappedValue: Value) {
35 | self.wrappedValue = wrappedValue
36 | self.outcome = .decodedSuccessfully
37 | }
38 |
39 | public let wrappedValue: Value
40 |
41 | let outcome: ResilientDecodingOutcome
42 |
43 | /**
44 | Transforms the value of a `Resilient` type.
45 | If `self` is a resilient array, care should be taken to ensure that the `value.count` == `transform(value).count` in order to not break the `results` property.
46 | */
47 | func map(transform: (Value) -> T) -> Resilient {
48 | Resilient(transform(wrappedValue), outcome: outcome)
49 | }
50 |
51 | #if DEBUG
52 | /**
53 | `subscript(dynamicMember:)` is defined in files like `ResilientArray+DecodingOutcome`, and is used to provide certain properties only on `@Resilient` properties of certain types. For instance `errors` and `results` are only present on resilient arrays. The reason we need to use `@dynamicMemberLookup` is so that we can add a generic constraint (which we can to `subscript`, but not to properties).
54 | `@dynamicMemberLookup` also cannot be declared on an extension, so must be declared here.
55 | */
56 | @dynamicMemberLookup
57 | public struct ProjectedValue {
58 | public let outcome: ResilientDecodingOutcome
59 |
60 | public var error: Error? {
61 | switch outcome {
62 | case .decodedSuccessfully, .keyNotFound, .valueWasNil:
63 | return nil
64 | case .recoveredFrom(let error, _):
65 | return error
66 | }
67 | }
68 | }
69 | public var projectedValue: ProjectedValue { ProjectedValue(outcome: outcome) }
70 | #endif
71 |
72 | }
73 |
74 | // MARK: Equatable
75 |
76 | extension Resilient: Equatable where Value: Equatable {
77 | public static func ==(lhs: Self, rhs: Self) -> Bool {
78 | lhs.wrappedValue == rhs.wrappedValue
79 | }
80 | }
81 |
82 | // MARK: Hashable
83 |
84 | extension Resilient: Hashable where Value: Hashable {
85 | public func hash(into hasher: inout Hasher) {
86 | hasher.combine(wrappedValue)
87 | }
88 | }
89 |
90 | // MARK: - Decoding Outcome
91 |
92 | #if DEBUG
93 | /**
94 | The outcome of decoding a `Resilient` type
95 | */
96 | public enum ResilientDecodingOutcome {
97 | /**
98 | A value was decoded successfully
99 | */
100 | case decodedSuccessfully
101 |
102 | /**
103 | The key was missing, and it was not treated as an error (for instance when decoding an `Optional`)
104 | */
105 | case keyNotFound
106 |
107 | /**
108 | The value was `nil`, and it was not treated as an error (for instance when decoding an `Optional`)
109 | */
110 | case valueWasNil
111 |
112 | /**
113 | An error was recovered from during decoding
114 | - parameter `wasReported`: Some errors are not reported, for instance `ArrayDecodingError`
115 | */
116 | case recoveredFrom(Error, wasReported: Bool)
117 | }
118 | #else
119 | /**
120 | In release, we don't want the decoding outcome mechanism taking up space, so we define an empty struct with `static` properties and functions which match the `enum` above. This reduces the number of places we need to use `#if DEBUG` substantially.
121 | */
122 | struct ResilientDecodingOutcome {
123 | static let decodedSuccessfully = Self()
124 | static let keyNotFound = Self()
125 | static let valueWasNil = Self()
126 | static let recoveredFromDebugOnlyError = Self()
127 | static func recoveredFrom(_: Error, wasReported: Bool) -> Self { Self() }
128 | }
129 | #endif
130 |
131 | // MARK: - Convenience
132 |
133 | extension KeyedDecodingContainer {
134 |
135 | /**
136 | Resiliently decodes a value for the specified key, using `fallback` if an error is encountered.
137 | - parameter behaveLikeOptional: If `true`, we don't report errors for missing keys and nil values
138 | */
139 | func resilientlyDecode(
140 | valueForKey key: Key,
141 | fallback: @autoclosure () -> T,
142 | behaveLikeOptional: Bool = true,
143 | body: (Decoder) throws -> Resilient = { Resilient(try T(from: $0)) }) -> Resilient
144 | {
145 | if behaveLikeOptional, !contains(key) {
146 | return Resilient(fallback(), outcome: .keyNotFound)
147 | }
148 | do {
149 | let decoder = try superDecoder(forKey: key)
150 | do {
151 | if behaveLikeOptional, try decoder.singleValueContainer().decodeNil() {
152 | return Resilient(fallback(), outcome: .valueWasNil)
153 | }
154 | return try body(decoder)
155 | } catch {
156 | decoder.reportError(error)
157 | return Resilient(fallback(), outcome: .recoveredFrom(error, wasReported: true))
158 | }
159 | } catch {
160 | /**
161 | There is no `Decoder` to report an error to here, but this case should almost never happen, as `superDecoder` is meant to wrap any and throw it only at the moment something tries to decode a value from it. For instance, `JSONDecoder` does not throw errors from this method: https://github.com/apple/swift/blob/88b093e9d77d6201935a2c2fb13f27d961836777/stdlib/public/Darwin/Foundation/JSONEncoder.swift#L1657-L1661
162 | */
163 | return Resilient(fallback(), outcome: .recoveredFrom(error, wasReported: false))
164 | }
165 | }
166 |
167 | }
168 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientRawRepresentableTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import XCTest
5 | import ResilientDecoding
6 |
7 | private struct ResilientRawRepresentableEnumWrapper: Decodable {
8 | @Resilient var resilientEnumWithFallback: ResilientEnumWithFallback
9 | @Resilient var resilientFrozenEnumWithFallback: ResilientFrozenEnumWithFallback
10 | @Resilient var optionalResilientEnum: ResilientEnum?
11 | @Resilient var optionalResilientFrozenEnum: ResilientFrozenEnum?
12 | @Resilient var optionalResilientEnumWithFallback: ResilientEnumWithFallback?
13 | @Resilient var optionalResilientFrozenEnumWithFallback: ResilientFrozenEnumWithFallback?
14 | }
15 |
16 | final class ResilientRawRepresentableEnumTests: XCTestCase {
17 |
18 | func testDecodesValidCasesWithoutErrors() throws {
19 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
20 | {
21 | "resilientEnumWithFallback": "existing",
22 | "resilientFrozenEnumWithFallback": "existing",
23 | "optionalResilientEnum": "existing",
24 | "optionalResilientFrozenEnum": "existing",
25 | "optionalResilientEnumWithFallback": "existing",
26 | "optionalResilientFrozenEnumWithFallback": "existing",
27 | }
28 | """)
29 | XCTAssertEqual(mock.resilientEnumWithFallback, .existing)
30 | XCTAssertEqual(mock.resilientFrozenEnumWithFallback, .existing)
31 | XCTAssertEqual(mock.optionalResilientEnum, .existing)
32 | XCTAssertEqual(mock.optionalResilientFrozenEnum, .existing)
33 | XCTAssertEqual(mock.optionalResilientEnumWithFallback, .existing)
34 | XCTAssertEqual(mock.optionalResilientFrozenEnumWithFallback, .existing)
35 | #if DEBUG
36 | XCTAssertNil(mock.$resilientEnumWithFallback.error)
37 | XCTAssertNil(mock.$resilientFrozenEnumWithFallback.error)
38 | XCTAssertNil(mock.$optionalResilientEnum.error)
39 | XCTAssertNil(mock.$optionalResilientFrozenEnum.error)
40 | XCTAssertNil(mock.$optionalResilientEnumWithFallback.error)
41 | XCTAssertNil(mock.$optionalResilientFrozenEnumWithFallback.error)
42 | #endif
43 | }
44 |
45 | func testDecodesNullOptionalValuesWithoutErrors() throws {
46 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
47 | {
48 | "resilientEnumWithFallback": "existing",
49 | "resilientFrozenEnumWithFallback": "existing",
50 | "optionalResilientEnum": null,
51 | "optionalResilientFrozenEnum": null,
52 | "optionalResilientEnumWithFallback": null,
53 | "optionalResilientFrozenEnumWithFallback": null,
54 | }
55 | """)
56 | XCTAssertNil(mock.optionalResilientEnum)
57 | XCTAssertNil(mock.optionalResilientFrozenEnum)
58 | XCTAssertNil(mock.optionalResilientEnumWithFallback)
59 | XCTAssertNil(mock.optionalResilientFrozenEnumWithFallback)
60 | #if DEBUG
61 | XCTAssertNil(mock.$optionalResilientEnum.error)
62 | XCTAssertNil(mock.$optionalResilientFrozenEnum.error)
63 | XCTAssertNil(mock.$optionalResilientEnumWithFallback.error)
64 | XCTAssertNil(mock.$optionalResilientFrozenEnumWithFallback.error)
65 | #endif
66 | }
67 |
68 | func testDecodesMissingOptionalValuesWithoutErrors() throws {
69 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
70 | {
71 | "resilientEnumWithFallback": "existing",
72 | "resilientFrozenEnumWithFallback": "existing",
73 | }
74 | """)
75 | XCTAssertNil(mock.optionalResilientEnum)
76 | XCTAssertNil(mock.optionalResilientFrozenEnum)
77 | XCTAssertNil(mock.optionalResilientEnumWithFallback)
78 | XCTAssertNil(mock.optionalResilientFrozenEnumWithFallback)
79 | #if DEBUG
80 | XCTAssertNil(mock.$optionalResilientEnum.error)
81 | XCTAssertNil(mock.$optionalResilientFrozenEnum.error)
82 | XCTAssertNil(mock.$optionalResilientEnumWithFallback.error)
83 | XCTAssertNil(mock.$optionalResilientFrozenEnumWithFallback.error)
84 | #endif
85 | }
86 |
87 | func testResilientlyDecodesMissingValues() throws {
88 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
89 | {
90 | }
91 | """,
92 | expectedErrorCount: 2)
93 | XCTAssertEqual(mock.resilientEnumWithFallback, .unknown)
94 | XCTAssertEqual(mock.resilientFrozenEnumWithFallback, .unknown)
95 | #if DEBUG
96 | XCTAssertNotNil(mock.$resilientEnumWithFallback.error)
97 | XCTAssertNotNil(mock.$resilientFrozenEnumWithFallback.error)
98 | #endif
99 | }
100 |
101 | func testResilientlyDecodesNovelCases() throws {
102 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
103 | {
104 | "resilientEnumWithFallback": "novel",
105 | "resilientFrozenEnumWithFallback": "novel",
106 | "optionalResilientEnum": "novel",
107 | "optionalResilientFrozenEnum": "novel",
108 | "optionalResilientEnumWithFallback": "novel",
109 | "optionalResilientFrozenEnumWithFallback": "novel",
110 | }
111 | """,
112 | expectedErrorCount: 3)
113 | XCTAssertEqual(mock.resilientEnumWithFallback, .unknown)
114 | XCTAssertEqual(mock.resilientFrozenEnumWithFallback, .unknown)
115 | XCTAssertNil(mock.optionalResilientEnum)
116 | XCTAssertNil(mock.optionalResilientFrozenEnum)
117 | XCTAssertEqual(mock.optionalResilientEnumWithFallback, .unknown)
118 | XCTAssertEqual(mock.optionalResilientFrozenEnumWithFallback, .unknown)
119 |
120 | #if DEBUG
121 | /// All properties provide an error for inspection, but only _frozen_ types report the error (hence "3" expected errors above)
122 | XCTAssertNotNil(mock.$resilientEnumWithFallback.error)
123 | XCTAssertNotNil(mock.$resilientFrozenEnumWithFallback.error)
124 | XCTAssertNotNil(mock.$optionalResilientEnum.error)
125 | XCTAssertNotNil(mock.$optionalResilientFrozenEnum.error)
126 | XCTAssertNotNil(mock.$optionalResilientEnumWithFallback.error)
127 | XCTAssertNotNil(mock.$optionalResilientFrozenEnumWithFallback.error)
128 | #endif
129 | }
130 |
131 | func testResilientlyDecodesInvalidCases() throws {
132 | let mock = try decodeMock(ResilientRawRepresentableEnumWrapper.self, """
133 | {
134 | "resilientEnumWithFallback": 1,
135 | "resilientFrozenEnumWithFallback": 2,
136 | "optionalResilientEnum": 3,
137 | "optionalResilientFrozenEnum": 4,
138 | "optionalResilientEnumWithFallback": 5,
139 | "optionalResilientFrozenEnumWithFallback": 6,
140 | }
141 | """,
142 | expectedErrorCount: 6)
143 | XCTAssertEqual(mock.resilientEnumWithFallback, .unknown)
144 | XCTAssertEqual(mock.resilientFrozenEnumWithFallback, .unknown)
145 | XCTAssertNil(mock.optionalResilientEnum)
146 | XCTAssertNil(mock.optionalResilientFrozenEnum)
147 | XCTAssertEqual(mock.optionalResilientEnumWithFallback, .unknown)
148 | XCTAssertEqual(mock.optionalResilientFrozenEnumWithFallback, .unknown)
149 |
150 | #if DEBUG
151 | /// Because this is invalid input and not a novel case, errors are provided at the property level _and_ reported (hence "6" expected errors above)
152 | XCTAssertNotNil(mock.$resilientEnumWithFallback.error)
153 | XCTAssertNotNil(mock.$resilientFrozenEnumWithFallback.error)
154 | XCTAssertNotNil(mock.$optionalResilientEnum.error)
155 | XCTAssertNotNil(mock.$optionalResilientFrozenEnum.error)
156 | XCTAssertNotNil(mock.$optionalResilientEnumWithFallback.error)
157 | XCTAssertNotNil(mock.$optionalResilientFrozenEnumWithFallback.error)
158 | #endif
159 | }
160 |
161 | }
162 |
--------------------------------------------------------------------------------
/Tests/ResilientDecodingTests/ResilientRawRepresentableDictionaryTests.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 4/23/20.
2 | // Copyright © 2020 Airbnb Inc. All rights reserved.
3 |
4 | import ResilientDecoding
5 | import XCTest
6 |
7 | private struct ResilientRawRepresentableDictionaryWrapper: Decodable {
8 | @Resilient var resilientDictionary: [String: ResilientEnum]
9 | @Resilient var optionalResilientDictionary: [String: ResilientEnum]?
10 | @Resilient var resilientDictionaryOfFrozenType: [String: ResilientFrozenEnum]
11 | @Resilient var optionalResilientDictionaryOfFrozenType: [String: ResilientFrozenEnum]?
12 | }
13 |
14 | final class ResilientRawRepresentableDictionaryTests: XCTestCase {
15 |
16 | func testDecodesValidInputWithoutErrors() throws {
17 | let mock = try decodeMock(ResilientRawRepresentableDictionaryWrapper.self, """
18 | {
19 | "resilientDictionary": { "1": "existing", "2": "existing" },
20 | "optionalResilientDictionary": { "1": "existing", "2": "existing" },
21 | "resilientDictionaryOfFrozenType": { "1": "existing", "2": "existing" },
22 | "optionalResilientDictionaryOfFrozenType": { "1": "existing", "2": "existing" },
23 | }
24 | """)
25 | XCTAssertEqual(mock.resilientDictionary, ["1": .existing, "2": .existing])
26 | XCTAssertEqual(mock.optionalResilientDictionary, ["1": .existing, "2": .existing])
27 | XCTAssertEqual(mock.resilientDictionaryOfFrozenType, ["1": .existing, "2": .existing])
28 | XCTAssertEqual(mock.optionalResilientDictionaryOfFrozenType, ["1": .existing, "2": .existing])
29 | #if DEBUG
30 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 0)
31 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 0)
32 | XCTAssertEqual(mock.$resilientDictionaryOfFrozenType.errors.count, 0)
33 | XCTAssertEqual(mock.$optionalResilientDictionaryOfFrozenType.errors.count, 0)
34 | #endif
35 | }
36 |
37 | /**
38 | - note: We keep the non-optional properties in the JSON so they do not report errors
39 | */
40 | func testDecodesWhenMissingKeysWithoutErrors() throws {
41 | let mock = try decodeMock(ResilientRawRepresentableDictionaryWrapper.self, """
42 | {
43 | "resilientDictionary": { "1": "existing", "2": "existing" },
44 | "resilientDictionaryOfFrozenType": { "1": "existing", "2": "existing" },
45 | }
46 | """)
47 | XCTAssertEqual(mock.optionalResilientDictionary, nil)
48 | XCTAssertEqual(mock.optionalResilientDictionaryOfFrozenType, nil)
49 | #if DEBUG
50 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 0)
51 | XCTAssertEqual(mock.$optionalResilientDictionaryOfFrozenType.errors.count, 0)
52 | #endif
53 | }
54 |
55 | /**
56 | - note: We keep the non-optional properties in the JSON so they do not report errors
57 | */
58 | func testDecodesNullValuesWithoutErrors() throws {
59 | let mock = try decodeMock(ResilientRawRepresentableDictionaryWrapper.self, """
60 | {
61 | "resilientDictionary": { "1": "existing", "2": "existing" },
62 | "optionalResilientDictionary": null,
63 | "resilientDictionaryOfFrozenType": { "1": "existing", "2": "existing" },
64 | "optionalResilientDictionaryOfFrozenType": null,
65 | }
66 | """)
67 | XCTAssertEqual(mock.optionalResilientDictionary, nil)
68 | XCTAssertEqual(mock.optionalResilientDictionaryOfFrozenType, nil)
69 | #if DEBUG
70 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 0)
71 | XCTAssertEqual(mock.$optionalResilientDictionaryOfFrozenType.errors.count, 0)
72 | #endif
73 | }
74 |
75 | func testResilientlyDecodesNovelCases() throws {
76 | let mock = try decodeMock(ResilientRawRepresentableDictionaryWrapper.self, """
77 | {
78 | "resilientDictionary": {
79 | "1": "existing",
80 | "2": "novel",
81 | "3": "existing",
82 | },
83 | "optionalResilientDictionary": {
84 | "1": "novel",
85 | "2": "existing",
86 | "3": "novel",
87 | },
88 | "resilientDictionaryOfFrozenType": {
89 | "1": "existing",
90 | "2": "novel",
91 | "3": "existing",
92 | },
93 | "optionalResilientDictionaryOfFrozenType": {
94 | "1": "novel",
95 | "2": "existing",
96 | "3": "novel",
97 | },
98 | }
99 | """,
100 | expectedErrorCount: 3)
101 | XCTAssertEqual(mock.resilientDictionary, ["1": .existing, "3": .existing])
102 | XCTAssertEqual(mock.optionalResilientDictionary, ["2": .existing])
103 | XCTAssertEqual(mock.resilientDictionaryOfFrozenType, ["1": .existing, "3": .existing])
104 | XCTAssertEqual(mock.optionalResilientDictionaryOfFrozenType, ["2": .existing])
105 |
106 | #if DEBUG
107 | /// All properties provide errors for inspection, but only _frozen_ types report the error (hence "3" expected errors above)
108 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 1)
109 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 2)
110 | XCTAssertEqual(mock.$resilientDictionaryOfFrozenType.errors.count, 1)
111 | XCTAssertEqual(mock.$optionalResilientDictionaryOfFrozenType.errors.count, 2)
112 | #endif
113 | }
114 |
115 | func testResilientlyDecodesInvalidCases() throws {
116 | let mock = try decodeMock(ResilientRawRepresentableDictionaryWrapper.self, """
117 | {
118 | "resilientDictionary": {
119 | "1": "existing",
120 | "2": 2,
121 | "3": "existing",
122 | },
123 | "optionalResilientDictionary": {
124 | "1": 1,
125 | "2": "existing",
126 | "3": 3,
127 | },
128 | "resilientDictionaryOfFrozenType": {
129 | "1": "existing",
130 | "2": 2,
131 | "3": "existing",
132 | },
133 | "optionalResilientDictionaryOfFrozenType": {
134 | "1": 1,
135 | "2": "existing",
136 | "3": 3,
137 | },
138 | }
139 | """,
140 | expectedErrorCount: 6)
141 | XCTAssertEqual(mock.resilientDictionary, ["1": .existing, "3": .existing])
142 | XCTAssertEqual(mock.optionalResilientDictionary, ["2": .existing])
143 | XCTAssertEqual(mock.resilientDictionaryOfFrozenType, ["1": .existing, "3": .existing])
144 | XCTAssertEqual(mock.optionalResilientDictionaryOfFrozenType, ["2": .existing])
145 |
146 | #if DEBUG
147 | /// All properties provide errors for inspection, but only _frozen_ types report the error (hence "6" expected errors above)
148 | XCTAssertEqual(mock.$resilientDictionary.errors.count, 1)
149 | XCTAssertEqual(mock.$optionalResilientDictionary.errors.count, 2)
150 | XCTAssertEqual(mock.$resilientDictionaryOfFrozenType.errors.count, 1)
151 | XCTAssertEqual(mock.$optionalResilientDictionaryOfFrozenType.errors.count, 2)
152 | #endif
153 | }
154 |
155 | /**
156 | When a `Dictionary` is decoded, the keys are not transformed by the `keyDecodingStrategy`.
157 | */
158 | func testKeyDecodingStrategyIsIgnored() throws {
159 | let decoder = JSONDecoder()
160 | decoder.keyDecodingStrategy = .convertFromSnakeCase
161 | struct Mock: Decodable {
162 | @Resilient var resilientDictionary: [String: Int]
163 | }
164 | let mock = try decoder.decode(Mock.self, from: """
165 | {
166 | "resilient_dictionary": {
167 | "the_number_one": 1,
168 | "the_number_two": 2,
169 | "the_number_three": 3,
170 | }
171 | }
172 | """.data(using: .utf8)!)
173 | XCTAssertEqual(mock.resilientDictionary, [
174 | "the_number_one": 1,
175 | "the_number_two": 2,
176 | "the_number_three": 3
177 | ])
178 | }
179 |
180 | }
181 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Resilient Decoding
2 |
3 | 
4 | [](https://github.com/apple/swift-package-manager)
5 | [](https://cocoapods.org/pods/ResilientDecoding)
6 | [](https://cocoapods.org/pods/ResilientDecoding)
7 | [](https://cocoapods.org/pods/ResilientDecoding)
8 |
9 | ## Introduction
10 |
11 | This package defines mechanisms to partially recover from errors when decoding `Decodable` types. It also aims to provide an ergonomic API for inspecting decoding errors during development and reporting them in production.
12 |
13 | More details follow, but here is a glimpse of what this package enables:
14 | ```swift
15 | struct Foo: Decodable {
16 | @Resilient var array: [Int]
17 | @Resilient var value: Int?
18 | }
19 | let foo = try JSONDecoder().decode(Foo.self, from: """
20 | {
21 | "array": [1, "2", 3],
22 | "value": "invalid",
23 | }
24 | """.data(using: .utf8)!)
25 | ```
26 | After running this code, `foo` will be a `Foo` where `foo.array == [1, 3]` and `foo.value == nil`. In DEBUG, `foo.$array.results` will be `[.success(1), .failure(DecodingError.dataCorrupted(…), .success(3)]` and `foo.$value.error` will be `DecodingError.dataCorrupted(…)`. This functionality is `DEBUG`-only so that we can maintain [no overhead in release builds](https://github.com/airbnb/ResilientDecoding/blob/8fac9a34b7cff8c8849081dee2630b8958f695cc/Tests/ResilientDecodingTests/MemoryTests.swift#L8-L20).
27 |
28 | ## Setup
29 |
30 | ### Swift Package Manager
31 |
32 | In your Package.swift:
33 | ```swift
34 | dependencies: [
35 | .package(name: "ResilientDecoding", url: "https://github.com/airbnb/ResilientDecoding.git", from: "1.0.0"),
36 | ]
37 | ```
38 |
39 | ### CocoaPods
40 |
41 | In your `Podfile`:
42 |
43 | ```
44 | platform :ios, '12.0'
45 | pod 'ResilientDecoding', '~> 1.0'
46 | ```
47 |
48 | ## Decoding
49 |
50 | The main interface to this package is the `@Resilient` property wrapper. It can be applied to four kinds of properties: `Optional`, `Array`, `Dictionary`, and custom types conforming to the `ResilientRawRepresentable` protocol that this package provides.
51 |
52 | ### `Optional`
53 |
54 | Optionals are the simplest type of property that can be made `Resilient`. A property written as `@Resilient var foo: Int?` will be initialized as `nil` and not throw an error if one is encountered during decoding (for instance, if the value for the `foo` key was a `String`).
55 |
56 | ### `Array`
57 |
58 | `Resilient` can also be applied to an array or an optional array (`[T]?`). A property written as `@Resilient var foo: [Int]` will be initialized with an empty array if the `foo` key is missing or if the value is something unexpected, like `String`. Likewise, if any _element_ of this array fails to decode, that element will be omitted. The optional array variant of this will set the value to `nil` if the key is missing or has a null value, and an empty array otherwise.
59 |
60 | ### `Dictionary`
61 |
62 | `Resilient` can also be applied to a (string-keyed) dictionary or an optional dictionary (`[String: T]?`). A property written as `@Resilient var foo: [String: Int]` will be initialized with an empty dictionary if the `foo` key is missing or if the value is something unexpected, like `String`. Likewise, if any _value_ in the dictionary fails to decode, that value will be omitted. The optional dictionary variant of this will set the value to `nil` if the key is missing or has a null value, and an empty array otherwise.
63 |
64 | ### `ResilientRawRepresentable`
65 |
66 | Custom types can conform to the `ResilientRawRepresentable` protocol which allows them to customize their behavior **when being decoded as a `Resilient` property** (it has no affect otherwise). `ResilientRawRepresentable` inherits from `RawRepresentable` and is meant to be conformed to primarily by `enum`s with a raw value. `ResilientRawRepresentable` has two static properties: `decodingFallback` and `isFrozen`.
67 |
68 | #### `decodingFallback`
69 | A `ResilientRawRepresentable` type can optionally define a `decodingFallback`, which allows it to be resiliently decoded without being wrapped in an optional. For instance, the following enum can be used in a property written `@Resilient var myEnum: MyEnum`:
70 | ```swift
71 | enum MyEnum: String, ResilientRawRepresentable {
72 | case existing
73 | case unknown
74 | static var decodingFallback: Self { .unknown }
75 | }
76 | ```
77 |
78 | **Note:** `Array`s and `Dictionary`s of `ResilientRawRepresentable`s _always_ omit elements instead of using the `decodingFallback`.
79 |
80 | #### `isFrozen`
81 | `isFrozen` controls whether new `RawValues` will report errors to `ResilientDecodingErrorReporter`. By default, `isFrozen` is `false`, which means that a `RawValue` for which `init(rawValue:)` returns `nil` will _not_ report an error. This is useful when you want older versions of your code to support new `enum` cases without reporting errors, for instance when evolving a backend API used by an iOS application. In this way, the property is analogous to Swift's `@frozen` attribute, though they achieve different goals. `isFrozen` has no effect on property-level errors.
82 |
83 | ## Inspecting Errors
84 |
85 | `Resilient` provides two mechanisms for inspecting errors, one designed for use during development and another designed for reporting unexpected errors in production.
86 |
87 | ### Property-Level Errors
88 |
89 | In `DEBUG` builds, `Resilient` properties provide a `projectedValue` with information about errors encountered during decoding. This information can be inspected using the `$property.outcome` property, which is an enum with cases including `keyNotFound` and `valueWasNil`. This is different from errors since the aformentioned two cases are actually not errors when the property value is `Optional`, for instance.
90 | Scalar types, such as `Optional` and `ResilientRawRepresentable`, also provide an `error` property. Developers can determine if an error ocurred during decoding by accessing `$foo.error` for a property written `@Resilient var foo: Int?`.
91 | `@Resilient` array properties provide two additional fields: `errors` and `results`. `errors` is the list of all errors that were recovered from when decoding the array. `results` interleaves these errors with elements of the array that were successfully decoded. For instance, the `results` for a property written `@Resilient var baz: [Int]` when decoding the JSON snippet `[1, 2, "3"]` would be two `.success` values followed by a `.failure`.
92 |
93 | ### `ResilientDecodingErrorReporter`
94 |
95 | In production, `ResilientDecodingErrorReporter` can be used to collate all errors encountered when decoding a type with `Resilient` properties. `JSONDecoder` provides a convenient `decode(_:from:reportResilientDecodingErrors:)` API which returns both the decoded value and the error digest if errors were encountered. More complex use cases require adding a `ResilientDecodingErrorReporter` to your `Decoder`'s `userInfo` as the value for the `.resilientDecodingErrorReporter` user info key. After decoding a type, you can call `flushReportedErrors` which will return an `ErrorDigest` if any errors are encountered. The digest can be used to access the underlying errors (`errorDigest.errors`) or be pretty-printed in `DEBUG` (`debugPrint(errorDigest)`).
96 |
97 | The pretty-printed digest looks something like this:
98 | ```
99 | resilientArrayProperty
100 | Index 1
101 | - Could not decode as `Int`
102 | Index 3
103 | - Could not decode as `Int`
104 | resilientRawRepresentableProperty
105 | - Unknown novel value "novel" (this error is not reported by default)
106 | ```
107 |
108 | **Note:** One difference the errors available on the property wrapper and those reported to the `ResilientDecodingErrorReporter`, is the latter _does not_ report `UnknownNovelValueError`s by default (`UnknownNovelValueError` is thrown when a non-frozen `ResilientRawRepresentable`'s `init(rawValue:)` returns `nil`). You can alter this behavior by calling `errors(includeUnknownNovelValueErrors: true)` on the error digest.
109 |
110 | ## Frequently Asked Questions
111 |
112 | ### Will `Resilient` work as expected when the wrapped type is a generic argument?
113 |
114 | No. If you have a type that is generic over `` and specify `@Resilient var someResilient: T` it will not matter if `T` is an array or dictionary, it will be treated as a single value.
115 |
116 | ### Why doesn't Resilient conform to `Encodable` when its value does?
117 |
118 | We don't explicitly conform `Resilient` to `Encodable` because the encoding may be lossy in the presence of errors. If you are sure that this isn't an issue for your use case, it should be simple to provide an `Encodable` conformance in your own module.
119 |
120 | ## More Details
121 |
122 | For more information about what how exactly a particular `Resilient` field will behave when it encounters a particular error, I recommend consulting the unit tests.
123 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ResilientRawRepresentable.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/31/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | /**
7 | A type that can be made `Resilient`
8 |
9 | For instance, if you declare the type
10 | ```
11 | enum MyEnum: ResilientRawRepresentable {
12 | case existing
13 | case unknown
14 | static let decodingFallback: MyEnum = .unknown
15 | }
16 | ```
17 | then any struct with a `Resilient` property with that type (for instance `@Resilient var myEnum: MyEnum`) will be set to `.unknown` in the event of a decoding failure.
18 | */
19 | public protocol ResilientRawRepresentable: Decodable, RawRepresentable where RawValue: Decodable {
20 |
21 | associatedtype DecodingFallback
22 |
23 | /**
24 | This value will be used when decoding a `Resilient` fails. For types overriding this property, the type should only ever be `Self`
25 | `ResilientRawRepresentable` types do not provide a `decodingFallback` by default. This is indicated by the associated `DecodingFallback` type being `Void`.
26 | - note: The `decodingFallback` will not be used if you are decoding an array where the element is a `ResilientRawRepresentable` type. Instead, they will be omitted.
27 | */
28 | static var decodingFallback: DecodingFallback { get }
29 |
30 | /**
31 | Override this property to return `true` to report errors when encountering a `RawValue` that does _not_ correspond to a value of this type. Failure to decode the `RawValue` will _always_ report an error.
32 | Defaults to `false`.
33 | */
34 | static var isFrozen: Bool { get }
35 |
36 | }
37 |
38 | /**
39 | Default implementations of protocol requirements
40 | */
41 | extension ResilientRawRepresentable {
42 | public static var isFrozen: Bool { false }
43 | public static var decodingFallback: Void { () }
44 | }
45 |
46 | // MARK: - Decoding
47 |
48 | /**
49 | Synthesized `Decodable` initializers are effectively equivalent to writing the following initializer:
50 | ```
51 | init(from decoder: Decoder) throws {
52 | let container = try decoder.container(keyedBy: SynthesizedCodingKeys.self)
53 | self.propertyA = try container.decode(TypeOfPropertyA.self, forKey: .propertyA)
54 | self.propertyB = try container.decode(TypeOfPropertyB.self, forKey: .propertyB)
55 | …and so on
56 | }
57 | ```
58 | By declaring these public methods here, if `TypeOfPropertyA` is a specialization of `Resilient` such that it matches one of the following method signatures, Swift will call that overload of the `decode(_:forKey)` method instead of the default implementation provided by `Foundation`. This allows us to perform custom logic to _resiliently_ recover from decoding errors.
59 | */
60 | extension KeyedDecodingContainer {
61 |
62 | /**
63 | Decodes a `ResilientRawRepresentable` type which provides a custom `decodingFallback`.
64 | */
65 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient
66 | where
67 | T.DecodingFallback == T
68 | {
69 | resilientlyDecode(
70 | valueForKey: key,
71 | fallback: .decodingFallback,
72 | /// For a non-optional `ResilientRawRepresentable`, a missing key or `nil` value are considered errors
73 | behaveLikeOptional: false,
74 | body: { Resilient(try ResilientRawRepresentableContainer(from: $0).value) })
75 | }
76 |
77 | /**
78 | Decodes a `ResilientRawRepresentable` optional.
79 | This is different from simply decoding a `Resilient` optional because non-frozen `enum` types will not report errors.
80 | */
81 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient
82 | where
83 | T.DecodingFallback == Void
84 | {
85 | resilientlyDecode(
86 | valueForKey: key,
87 | fallback: nil,
88 | body: { Resilient(try ResilientRawRepresentableContainer(from: $0).value).map { $0 } })
89 | }
90 |
91 | /**
92 | Decodes a `ResilientRawRepresentable` optional which provides a `decodingFallback`.
93 | This is different from simply decoding a `Resilient` optional because non-frozen `enum` types will not report errors.
94 | - note: The `Resilient` value will be `nil`, if we decode `nil` for this key or the key is missing. In all other cases we use `T.decodingFallback`.
95 | */
96 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient
97 | where
98 | T.DecodingFallback == T
99 | {
100 | resilientlyDecode(
101 | valueForKey: key,
102 | fallback: nil,
103 | body: { decoder in
104 | do {
105 | return Resilient(try ResilientRawRepresentableContainer(from: decoder).value).map { $0 }
106 | } catch {
107 | decoder.reportError(error)
108 | return Resilient(T.decodingFallback, outcome: .recoveredFrom(error, wasReported: true))
109 | }
110 | })
111 | }
112 |
113 | /**
114 | When decoding an array of `ResilientRawRepresentable` values, elements are omitted as errors are encountered. The `decodingFallback` is never used.
115 | */
116 | public func decode(_ type: Resilient<[T]>.Type, forKey key: Key) throws -> Resilient<[T]>
117 | {
118 | resilientlyDecode(valueForKey: key, fallback: []) { decoder in
119 | decoder.resilientlyDecodeArray(
120 | of: ResilientRawRepresentableContainer.self,
121 | transform: { $0.value })
122 | }
123 | }
124 |
125 | /**
126 | When decoding an array of `ResilientRawRepresentable` values, elements are omitted as errors are encountered. The `decodingFallback` is never used.
127 | */
128 | public func decode(_ type: Resilient<[T]?>.Type, forKey key: Key) throws -> Resilient<[T]?>
129 | {
130 | resilientlyDecode(valueForKey: key, fallback: nil) { decoder in
131 | decoder.resilientlyDecodeArray(
132 | of: ResilientRawRepresentableContainer.self,
133 | transform: { $0.value })
134 | /// Transforms `Resilient<[String: T]>` into `Resilient<[String: T]?>`
135 | .map { $0 }
136 | }
137 | }
138 |
139 | /**
140 | When decoding a dictionary of `ResilientRawRepresentable` values, elements are omitted as errors are encountered. The `decodingFallback` is never used.
141 | */
142 | public func decode(_ type: Resilient<[String: T]>.Type, forKey key: Key) throws -> Resilient<[String: T]>
143 | {
144 | resilientlyDecode(valueForKey: key, fallback: [:]) { decoder in
145 | decoder.resilientlyDecodeDictionary(
146 | of: ResilientRawRepresentableContainer.self,
147 | transform: { $0.value })
148 | }
149 | }
150 |
151 | /**
152 | When decoding a dictionary of `ResilientRawRepresentable` values, elements are omitted as errors are encountered. The `decodingFallback` is never used.
153 | */
154 | public func decode(_ type: Resilient<[String: T]?>.Type, forKey key: Key) throws -> Resilient<[String: T]?>
155 | {
156 | resilientlyDecode(valueForKey: key, fallback: nil) { decoder in
157 | decoder.resilientlyDecodeDictionary(
158 | of: ResilientRawRepresentableContainer.self,
159 | transform: { $0.value })
160 | /// Transforms `Resilient<[String: T]>` into `Resilient<[String: T]?>`
161 | .map { $0 }
162 | }
163 | }
164 |
165 |
166 | }
167 |
168 | // MARK: - Catch Common Mistakes
169 |
170 | extension KeyedDecodingContainer {
171 |
172 | /**
173 | If a type does not provide a `decodingFallback`, it cannot be resiliently decoded unless the property is marked optional.
174 | */
175 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient
176 | where
177 | T.DecodingFallback == Void
178 | {
179 | assertionFailure()
180 | return Resilient(try decode(T.self, forKey: key))
181 | }
182 |
183 | /**
184 | This method will be called if a `ResilientRawRepresentable` type defines a `decodingFallback` that isn't `Void` or `Self`. This is likely a mistake, since only those types affect the behavior of `ResilientRawRepresentable`.
185 | */
186 | public func decode(_ type: Resilient.Type, forKey key: Key) throws -> Resilient
187 | {
188 | assertionFailure()
189 | return Resilient(try decode(T.self, forKey: key))
190 | }
191 |
192 | }
193 |
194 | // MARK: - Private
195 |
196 | private struct ResilientRawRepresentableContainer: Decodable {
197 | let value: Value
198 | init(from decoder: Decoder) throws {
199 | let rawValue = try decoder.singleValueContainer().decode(Value.RawValue.self)
200 | if let value = Value(rawValue: rawValue) {
201 | self.value = value
202 | } else {
203 | if Value.isFrozen {
204 | /**
205 | Ideally, we would just call `try Value(from: decoder)` at the top of this function if `Value.isFrozen` and use the error thrown by `Foundation`. Unfortunately, this fails when decoding a type whose `RawValue` is backed by primitive type (like how `URL` is backed by `String`). The URL test in `BugTests` demonstrates this behavior. I believe it is related to this issue: https://forums.swift.org/t/url-fails-to-decode-when-it-is-a-generic-argument-and-genericargument-from-decoder-is-used/36238
206 | */
207 | let context = DecodingError.Context(
208 | codingPath: decoder.codingPath,
209 | debugDescription: "Cannot initialize \(Value.self) from invalid raw value \(rawValue)")
210 | throw DecodingError.dataCorrupted(context)
211 | } else {
212 | throw UnknownNovelValueError(novelValue: rawValue)
213 | }
214 | }
215 | }
216 | }
217 |
--------------------------------------------------------------------------------
/Sources/ResilientDecoding/ErrorReporting.swift:
--------------------------------------------------------------------------------
1 | // Created by George Leontiev on 3/25/20.
2 | // Copyright © 2020 Airbnb Inc.
3 |
4 | import Foundation
5 |
6 | // MARK: - Enabling Error Reporting
7 |
8 | extension CodingUserInfoKey {
9 |
10 | public static let resilientDecodingErrorReporter = CodingUserInfoKey(rawValue: "ResilientDecodingErrorReporter")!
11 |
12 | }
13 |
14 | extension Dictionary where Key == CodingUserInfoKey, Value == Any {
15 |
16 | /**
17 | Creates and registers a `ResilientDecodingErrorReporter` with this `userInfo` dictionary. Any `Resilient` properties which are decoded by a `Decoder` with this user info will report their errors to the returned error reporter.
18 | - note: May only be called once on a particular `userInfo` dictionary
19 | */
20 | public mutating func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter {
21 | let errorReporter = ResilientDecodingErrorReporter()
22 | _ = replaceResilientDecodingErrorReporter(with: errorReporter)
23 | return errorReporter
24 | }
25 |
26 | /**
27 | Replaces the existing error reporter with the provided one
28 | - returns: The previous value of the `resilientDecodingErrorReporter` key, which can be used to restore this dictionary to its original state.
29 | */
30 | fileprivate mutating func replaceResilientDecodingErrorReporter(with errorReporter: ResilientDecodingErrorReporter) -> Any? {
31 | if let existingValue = self[.resilientDecodingErrorReporter] {
32 | assertionFailure()
33 | if let existingReporter = existingValue as? ResilientDecodingErrorReporter {
34 | existingReporter.currentDigest.mayBeMissingReportedErrors = true
35 | }
36 | }
37 | self[.resilientDecodingErrorReporter] = errorReporter
38 | return errorReporter
39 | }
40 |
41 | }
42 |
43 | extension JSONDecoder {
44 |
45 | /**
46 | Creates and registers a `ResilientDecodingErrorReporter` with this `JSONDecoder`. Any `Resilient` properties which this `JSONDecoder` decodes will report their errors to the returned error reporter.
47 | - note: May only be called once per `JSONDecoder`
48 | */
49 | public func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter {
50 | userInfo.enableResilientDecodingErrorReporting()
51 | }
52 |
53 | public func decode(_ type: T.Type, from data: Data, reportResilientDecodingErrors: Bool) throws -> (T, ErrorDigest?) {
54 | guard reportResilientDecodingErrors else {
55 | return (try decode(T.self, from: data), nil)
56 | }
57 | let errorReporter = ResilientDecodingErrorReporter()
58 | let oldValue = userInfo.replaceResilientDecodingErrorReporter(with: errorReporter)
59 | let value = try decode(T.self, from: data)
60 | userInfo[.resilientDecodingErrorReporter] = oldValue
61 | return (value, errorReporter.flushReportedErrors())
62 | }
63 |
64 | }
65 |
66 | // MARK: - Accessing Reported Errors
67 |
68 | public final class ResilientDecodingErrorReporter {
69 |
70 | /**
71 | Creates a `ResilientDecodingErrorReporter`, which is only useful if it is the value for the key `resilientDecodingErrorReporter` in a `Decoder`'s `userInfo`
72 | */
73 | public init() { }
74 |
75 | /**
76 | This is meant to be called immediately after decoding a `Decodable` type from a `Decoder`.
77 | - returns: Any errors encountered up to this point in time
78 | */
79 | public func flushReportedErrors() -> ErrorDigest? {
80 | let digest = hasErrors ? currentDigest : nil
81 | hasErrors = false
82 | currentDigest = ErrorDigest()
83 | return digest
84 | }
85 |
86 | /**
87 | This should only ever be called by `Decoder.resilientDecodingHandled` when an error is handled, consider calling that method instead.
88 | It is `internal` and not `fileprivate` only to allow us to split the up the two files.
89 | */
90 | func resilientDecodingHandled(_ error: Error, at path: [String]) {
91 | hasErrors = true
92 | currentDigest.root.insert(error, at: path)
93 | }
94 |
95 | fileprivate var currentDigest = ErrorDigest()
96 | private var hasErrors = false
97 |
98 | }
99 |
100 | public struct ErrorDigest {
101 |
102 | public var errors: [Error] { errors(includeUnknownNovelValueErrors: false) }
103 |
104 | public func errors(includeUnknownNovelValueErrors: Bool) -> [Error] {
105 | let allErrors: [Error]
106 | if mayBeMissingReportedErrors {
107 | allErrors = [MayBeMissingReportedErrors()] + root.errors
108 | } else {
109 | allErrors = root.errors
110 | }
111 | return allErrors.filter { includeUnknownNovelValueErrors || !($0 is UnknownNovelValueError) }
112 | }
113 |
114 | /**
115 | This should only ever be set from `Decoder.enableResilientDecodingErrorReporting` to signify that reporting has been enabled multiple times and the first `ResilientDecodingErrorReporter` may be missing errors. This behavior is behind an `assert` so it is highly unlikely to happen in production.
116 | */
117 | fileprivate var mayBeMissingReportedErrors: Bool = false
118 |
119 | fileprivate struct Node {
120 | private var children: [String: Node] = [:]
121 | private var shallowErrors: [Error] = []
122 |
123 | /**
124 | Inserts an error at the provided path
125 | */
126 | mutating func insert(_ error: Error, at path: Path)
127 | where Path.Element == String
128 | {
129 | if let next = path.first {
130 | children[next, default: Node()].insert(error, at: path.dropFirst())
131 | } else {
132 | shallowErrors.append(error)
133 | }
134 | }
135 |
136 | var errors: [Error] {
137 | shallowErrors + children.flatMap { $0.value.errors }
138 | }
139 | }
140 | fileprivate var root = Node()
141 |
142 | }
143 |
144 | // MARK: - Reporting Errors
145 |
146 | extension Decoder {
147 |
148 | /**
149 | Reports an error which did not cause decoding to fail. This error can be accessed after decoding is complete using `ResilientDecodingErrorReporter`. Care should be taken that this is called on the most relevant `Decoder` object, since this method uses the `Decoder`'s `codingPath` to place the error in the correct location in the tree.
150 | */
151 | public func reportError(_ error: Swift.Error) {
152 | guard let errorReporterAny = userInfo[.resilientDecodingErrorReporter] else {
153 | return
154 | }
155 | /**
156 | Check that we haven't hit the very unlikely case where someone has overriden our user info key with something we do not expect.
157 | */
158 | guard let errorReporter = errorReporterAny as? ResilientDecodingErrorReporter else {
159 | assertionFailure()
160 | return
161 | }
162 | errorReporter.resilientDecodingHandled(error, at: codingPath.map { $0.stringValue })
163 | }
164 |
165 | }
166 |
167 | // MARK: - Pretty Printing
168 |
169 | #if DEBUG
170 |
171 | extension ErrorDigest: CustomDebugStringConvertible {
172 | public var debugDescription: String {
173 | root.debugDescriptionLines.joined(separator: "\n")
174 | }
175 | }
176 |
177 | extension ErrorDigest.Node {
178 |
179 | var debugDescriptionLines: [String] {
180 | let errorLines = shallowErrors.map { "- " + $0.abridgedDescription }.sorted()
181 | let childrenLines = children
182 | .sorted(by: { $0.key < $1.key }).flatMap { child in
183 | [ child.key ] + child.value.debugDescriptionLines.map { " " + $0 }
184 | }
185 | return errorLines + childrenLines
186 | }
187 |
188 | }
189 |
190 | private extension Error {
191 |
192 | /**
193 | An abridged description which does not include the coding path
194 | */
195 | var abridgedDescription: String {
196 | switch self {
197 | case let decodingError as DecodingError:
198 | switch decodingError {
199 | case .dataCorrupted:
200 | return "Data corrupted"
201 | case .keyNotFound(let key, _):
202 | return "Key \"\(key.stringValue)\" not found"
203 | case .typeMismatch(let attempted, _):
204 | return "Could not decode as `\(attempted)`"
205 | case .valueNotFound(let attempted, _):
206 | return "Expected `\(attempted)` but found null instead"
207 | @unknown default:
208 | return localizedDescription
209 | }
210 | case let error as UnknownNovelValueError:
211 | return "Unknown novel value \"\(error.novelValue)\" (this error is not reported by default)"
212 | default:
213 | return localizedDescription
214 | }
215 | }
216 |
217 | }
218 |
219 | #endif
220 |
221 | // MARK: - Specific Errors
222 |
223 | /**
224 | In the unlikely event that `enableResilientDecodingErrorReporting()` is called multiple times, this error will be reported to the earlier `ResilientDecodingErrorReporter` to signify that the later one may have eaten some of its errors.
225 | */
226 | private struct MayBeMissingReportedErrors: Error { }
227 |
228 | /**
229 | An error which is surfaced at the property level but is not reported via `ResilientDecodingErrorReporter` by default (it can still be accessed by calling `errorDigest.errors(includeUnknownNovelValueErrors: true)`). This error is meant to indicate that the client detected a type it does not understand but believes to be valid, for instance a novel `case` of a `String`-backed `enum`.
230 | This is primarily used by `ResilientRawRepresentable`, but more complex use-cases exist where it is desirable to suppress error reporting but it would be awkward to implement using `ResilientRawRepresentable`. One such example is a type which inspects a `type` key before deciding how to decode the rest of the data (this pattern is often used to decode `enum`s with associated values). If it is desirable to suppress error reporting when encountering a new `type`, the custom type can explicitly throw this error.
231 | */
232 | public struct UnknownNovelValueError: Error {
233 |
234 | /**
235 | The raw value for which `init(rawValue:)` returned `nil`.
236 | */
237 | public let novelValue: Any
238 |
239 | /**
240 | - parameter novelValue: A value which is believed to be valid but the code does not know how to handle.
241 | */
242 | public init(novelValue: T) {
243 | self.novelValue = novelValue
244 | }
245 |
246 | }
247 |
--------------------------------------------------------------------------------