├── .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 | ![](https://github.com/airbnb/ResilientDecoding/workflows/Build/badge.svg) 4 | [![Swift Package Manager compatible](https://img.shields.io/badge/SPM-compatible-4BC51D.svg?style=flat)](https://github.com/apple/swift-package-manager) 5 | [![Version](https://img.shields.io/cocoapods/v/ResilientDecoding.svg)](https://cocoapods.org/pods/ResilientDecoding) 6 | [![License](https://img.shields.io/cocoapods/l/ResilientDecoding.svg)](https://cocoapods.org/pods/ResilientDecoding) 7 | [![Platform](https://img.shields.io/badge/platform-watchos%20%7C%20ios%20%7C%20tvos%20%7C%20macos%20%7C%20linux-lightgrey.svg?style=flat)](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 | --------------------------------------------------------------------------------