diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5db8a84..181ab37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: runs-on: macos-15 strategy: matrix: - xcode: ['16.1'] + xcode: ['16.4'] config: ['debug', 'release'] steps: diff --git a/README.md b/README.md index 6513f71..8429ed0 100644 --- a/README.md +++ b/README.md @@ -120,10 +120,10 @@ extension User: Codable { ### PolymorphicCodable -`PolymorphicCodable` provides functionality to easily decode polymorphic types from JSON. It includes several interfaces like `PolymorphicIdentifiable`, `PolymorphicCodableStrategy`, and property wrappers like `PolymorphicValue` and `PolymorphicArrayValue`. +`PolymorphicCodable` provides functionality to easily decode polymorphic types from JSON. This functionality provides Swift implementation for the OpenAPI Specification's `oneOf` pattern, allowing type-safe handling of multiple possible schemas. It includes several interfaces like `PolymorphicIdentifiable`, `PolymorphicCodableStrategy`, and property wrappers like `PolymorphicValue` and `PolymorphicArrayValue`. **Parameters:** -- `identifierCodingKey`: Specifies the JSON key used to determine the type of object being decoded. Defaults to "type" if not specified, allowing you to omit this parameter when using the default value. +- `identifierCodingKey`: Specifies the JSON key used to determine the type of object being decoded. This parameter corresponds to the `discriminator.propertyName` in OpenAPI Specification's oneOf definition. Defaults to "type" if not specified, allowing you to omit this parameter when using the default value. - `fallbackType`: Defines a default type to use when the identifier in the JSON doesn't match any of the registered types, preventing decoding failures for unknown types. If this parameter is omitted and an unknown type identifier is encountered during decoding, a decoding error will be thrown. The following example demonstrates how to decode dynamic JSON content where the type of object is determined at runtime: @@ -277,3 +277,4 @@ This project is licensed under the MIT. See LICENSE for details. - PolymorphicCodable was inspired by [Encode and decode polymorphic types in Swift](https://nilcoalescing.com/blog/BringingPolymorphismToCodable/). - AnyCodable was adapted from [Flight-School/AnyCodable](https://github.com/Flight-School/AnyCodable). - BetterCodable was adapted from [marksands/BetterCodable](https://github.com/marksands/BetterCodable). +- ResilientDecodingErrorReporter was adapted from [airbnb/ResilientDecoding](https://github.com/airbnb/ResilientDecoding). diff --git a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift index 5e20573..0219c4d 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DataValue/DataValue.swift @@ -25,14 +25,33 @@ public protocol DataValueCodableStrategy { public struct DataValue { public var wrappedValue: Coder.DataType + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: Coder.DataType) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: Coder.DataType, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DataValue: Decodable { public init(from decoder: Decoder) throws { - self.wrappedValue = try Coder.decode(String(from: decoder)) + do { + let stringValue = try String(from: decoder) + self.wrappedValue = try Coder.decode(stringValue) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } } } @@ -42,6 +61,16 @@ extension DataValue: Encodable { } } -extension DataValue: Equatable where Coder.DataType: Equatable {} -extension DataValue: Hashable where Coder.DataType: Hashable {} +extension DataValue: Equatable where Coder.DataType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DataValue: Hashable where Coder.DataType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DataValue: Sendable where Coder.DataType: Sendable {} diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift index 357ea7d..73f929b 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/DateValue.swift @@ -25,15 +25,33 @@ public protocol DateValueCodableStrategy { public struct DateValue { public var wrappedValue: Date + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: Date) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Date, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DateValue: Decodable where Formatter.RawValue: Decodable { public init(from decoder: Decoder) throws { - let value = try Formatter.RawValue(from: decoder) - self.wrappedValue = try Formatter.decode(value) + do { + let value = try Formatter.RawValue(from: decoder) + self.wrappedValue = try Formatter.decode(value) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } } } @@ -45,7 +63,7 @@ extension DateValue: Encodable where Formatter.RawValue: Encodable { } extension DateValue: Equatable { - public static func == (lhs: DateValue, rhs: DateValue) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift index baa5f2a..e9f3957 100644 --- a/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/DateValue/OptionalDateValue.swift @@ -26,19 +26,39 @@ public protocol OptionalDateValueCodableStrategy { public struct OptionalDateValue { public var wrappedValue: Date? + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: Date?) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: Date?, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension OptionalDateValue: Decodable where Formatter.RawValue: Decodable { public init(from decoder: Decoder) throws { do { let value = try Formatter.RawValue(from: decoder) - self.wrappedValue = try Formatter.decode(value) + do { + self.wrappedValue = try Formatter.decode(value) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } } catch DecodingError.valueNotFound(let rawType, _) where rawType == Formatter.RawValue.self { self.wrappedValue = nil + self.outcome = .valueWasNil } catch { + decoder.reportError(error) throw error } } @@ -52,7 +72,7 @@ extension OptionalDateValue: Encodable where Formatter.RawValue: Encodable { } extension OptionalDateValue: Equatable { - public static func == (lhs: OptionalDateValue, rhs: OptionalDateValue) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } diff --git a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift index 60ad2d9..4d3ae59 100644 --- a/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift +++ b/Sources/KarrotCodableKit/BetterCodable/Defaults/DefaultCodable.swift @@ -16,6 +16,16 @@ public protocol DefaultCodableStrategy { /// The fallback value used when decoding fails static var defaultValue: DefaultValue { get } + + /// When true, unknown raw values for RawRepresentable types will be reported as errors. + /// When false, unknown raw values will use the defaultValue without reporting an error. + /// Defaults to false. + static var isFrozen: Bool { get } +} + +/// Default implementation +extension DefaultCodableStrategy { + public static var isFrozen: Bool { false } } /// Decodes values with a reasonable default value @@ -26,21 +36,55 @@ public protocol DefaultCodableStrategy { public struct DefaultCodable { public var wrappedValue: Default.DefaultValue + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: Default.DefaultValue) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: Default.DefaultValue, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome } + + #if DEBUG + public var projectedValue: ResilientProjectedValue { ResilientProjectedValue(outcome: outcome) } + #endif } extension DefaultCodable where Default.Type == Default.DefaultValue.Type { public init(wrappedValue: Default) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } } extension DefaultCodable: Decodable { public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - self.wrappedValue = (try? container.decode(Default.DefaultValue.self)) ?? Default.defaultValue + + // Check for nil first + if container.decodeNil() { + #if DEBUG + self.init(wrappedValue: Default.defaultValue, outcome: .valueWasNil) + #else + self.init(wrappedValue: Default.defaultValue) + #endif + return + } + + do { + let value = try container.decode(Default.DefaultValue.self) + self.init(wrappedValue: value) + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: Default.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: Default.defaultValue) + #endif + } } } @@ -51,8 +95,18 @@ extension DefaultCodable: Encodable where Default.DefaultValue: Encodable { } } -extension DefaultCodable: Equatable where Default.DefaultValue: Equatable {} -extension DefaultCodable: Hashable where Default.DefaultValue: Hashable {} +extension DefaultCodable: Equatable where Default.DefaultValue: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DefaultCodable: Hashable where Default.DefaultValue: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DefaultCodable: Sendable where Default.DefaultValue: Sendable {} // MARK: - KeyedDecodingContainer @@ -64,10 +118,33 @@ extension KeyedDecodingContainer { /// /// Decodes successfully if key is available if not fallsback to the default value provided. public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode normally if let value = try decodeIfPresent(DefaultCodable

.self, forKey: key) { return value } else { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } } @@ -78,23 +155,139 @@ extension KeyedDecodingContainer { /// the data provider might be sending the value as different types. If everything fails defaults to /// the `defaultValue` provided by the strategy. public func decode(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

{ + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil first + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + do { let value = try decode(Bool.self, forKey: key) return DefaultCodable(wrappedValue: value) } catch let error { - guard let decodingError = error as? DecodingError, - case .typeMismatch = decodingError else { + guard + let decodingError = error as? DecodingError, + case .typeMismatch = decodingError + else { + // Report error and use default + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } - if let intValue = try? decodeIfPresent(Int.self, forKey: key), - let bool = Bool(exactly: NSNumber(value: intValue)) { + if + let intValue = try? decodeIfPresent(Int.self, forKey: key), + let bool = Bool(exactly: NSNumber(value: intValue)) + { return DefaultCodable(wrappedValue: bool) - } else if let stringValue = try? decodeIfPresent(String.self, forKey: key), - let bool = Bool(stringValue) { + } else if + let stringValue = try? decodeIfPresent(String.self, forKey: key), + let bool = Bool(stringValue) + { return DefaultCodable(wrappedValue: bool) } else { + // Type mismatch - report error + let decoder = try superDecoder(forKey: key) + decoder.reportError(decodingError) + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(decodingError, wasReported: true)) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + } + } + + /// Decodes a DefaultCodable where the strategy's DefaultValue is RawRepresentable + /// + /// This method provides special handling for RawRepresentable types: + /// - If `isFrozen` is false (default), unknown raw values result in UnknownNovelValueError and use the default value + /// - If `isFrozen` is true, unknown raw values result in DecodingError and use the default value + public func decode

(_: DefaultCodable

.Type, forKey key: Key) throws -> DefaultCodable

+ where P.DefaultValue: RawRepresentable, P.DefaultValue.RawValue: Decodable + { + // Check if key exists + if !contains(key) { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .keyNotFound) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Check for nil + if (try? decodeNil(forKey: key)) == true { + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .valueWasNil) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif + } + + // Try to decode the raw value + do { + let rawValue = try decode(P.DefaultValue.RawValue.self, forKey: key) + + // Try to create the enum from raw value + if let value = P.DefaultValue(rawValue: rawValue) { + return DefaultCodable(wrappedValue: value) + } else { + // Unknown raw value + let error = Self.createUnknownRawValueError( + for: P.DefaultValue.self, + rawValue: rawValue, + codingPath: codingPath + [key], + isFrozen: P.isFrozen + ) + + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else return DefaultCodable(wrappedValue: P.defaultValue) + #endif } + } catch { + // Decoding the raw value failed (e.g., type mismatch) + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + + #if DEBUG + return DefaultCodable(wrappedValue: P.defaultValue, outcome: .recoveredFrom(error, wasReported: true)) + #else + return DefaultCodable(wrappedValue: P.defaultValue) + #endif } } + + private static func createUnknownRawValueError( + for type: T.Type, + rawValue: T.RawValue, + codingPath: [CodingKey], + isFrozen: Bool + ) -> Error { + guard isFrozen else { return UnknownNovelValueError(novelValue: rawValue) } + let context = DecodingError.Context( + codingPath: codingPath, + debugDescription: "Cannot initialize \(type) from invalid raw value \(rawValue)" + ) + return DecodingError.dataCorrupted(context) + } } diff --git a/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift b/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift new file mode 100644 index 0000000..e4d2522 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/Extensions/Result+Extension.swift @@ -0,0 +1,35 @@ +// +// Result+Extension.swift +// KarrotCodableKit +// +// Created by elon on 7/31/25. +// + +import Foundation + +extension Result { + package var isSuccess: Bool { + switch self { + case .success: true + case .failure: false + } + } + + package var isFailure: Bool { + !isSuccess + } + + package var success: Success? { + try? get() + } + + package var failure: Failure? { + switch self { + + case .success: + nil + case .failure(let error): + error + } + } +} diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift index 588b752..3069a8e 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessArray.swift @@ -5,7 +5,6 @@ // Created by Elon on 4/9/25. // - import Foundation /// Decodes Arrays by attempting to decode its elements into their preferred types. @@ -19,9 +18,23 @@ import Foundation public struct LosslessArray { public var wrappedValue: [T] + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientArrayProjectedValue { + ResilientArrayProjectedValue(outcome: outcome) } + #endif } extension LosslessArray: Decodable where T: Decodable { @@ -31,16 +44,36 @@ extension LosslessArray: Decodable where T: Decodable { var container = try decoder.unkeyedContainer() var elements: [T] = [] + #if DEBUG + var results: [Result] = [] + #endif + while !container.isAtEnd { do { let value = try container.decode(LosslessValue.self).wrappedValue elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif } catch { _ = try? container.decode(AnyDecodableValue.self) + decoder.reportError(error) + #if DEBUG + results.append(.failure(error)) + #endif } } - self.wrappedValue = elements + #if DEBUG + if elements.count == results.count { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + } else { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + self.init(wrappedValue: elements) + #endif } } @@ -50,6 +83,16 @@ extension LosslessArray: Encodable where T: Encodable { } } -extension LosslessArray: Equatable where T: Equatable {} -extension LosslessArray: Hashable where T: Hashable {} +extension LosslessArray: Equatable where T: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LosslessArray: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LosslessArray: Sendable where T: Sendable {} diff --git a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift index c4e61b9..1915334 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LosslessValue/LosslessValue.swift @@ -31,22 +31,47 @@ public struct LosslessValueCodable: Codable public var wrappedValue: Strategy.Value + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: Strategy.Value) { self.wrappedValue = wrappedValue self.type = Strategy.Value.self + self.outcome = .decodedSuccessfully + } + + init( + wrappedValue: Strategy.Value, + outcome: ResilientDecodingOutcome, + type: LosslessStringCodable.Type + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.type = type } + #if DEBUG + public var projectedValue: ResilientProjectedValue { + ResilientProjectedValue(outcome: outcome) + } + #endif + public init(from decoder: Decoder) throws { do { self.wrappedValue = try Strategy.Value(from: decoder) self.type = Strategy.Value.self - } catch let error { - guard let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first, - let value = Strategy.Value("\(rawValue)") - else { throw error } + self.outcome = .decodedSuccessfully + } catch { + guard + let rawValue = Strategy.losslessDecodableTypes.lazy.compactMap({ $0(decoder) }).first, + let value = Strategy.Value("\(rawValue)") + else { + decoder.reportError(error) + throw error + } self.wrappedValue = value self.type = Swift.type(of: rawValue) + self.outcome = .decodedSuccessfully } } @@ -63,7 +88,7 @@ public struct LosslessValueCodable: Codable } extension LosslessValueCodable: Equatable where Strategy.Value: Equatable { - public static func == (lhs: LosslessValueCodable, rhs: LosslessValueCodable) -> Bool { + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.wrappedValue == rhs.wrappedValue } } @@ -122,5 +147,3 @@ public struct LosslessDefaultStrategy: LosslessDec public typealias LosslessValue< T: LosslessStringCodable > = LosslessValueCodable> - - diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift index 68ff363..dd68a75 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyArray.swift @@ -16,28 +16,84 @@ import Foundation public struct LossyArray { public var wrappedValue: [T] + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: [T]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully } + + init(wrappedValue: [T], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + public var projectedValue: ResilientArrayProjectedValue { ResilientArrayProjectedValue(outcome: outcome) } + #endif } extension LossyArray: Decodable where T: Decodable { private struct AnyDecodableValue: Decodable {} public init(from decoder: Decoder) throws { - var container = try decoder.unkeyedContainer() - - var elements: [T] = [] - while !container.isAtEnd { - do { - let value = try container.decode(T.self) - elements.append(value) - } catch { - _ = try? container.decode(AnyDecodableValue.self) + do { + // Check for null first + let singleValueContainer = try decoder.singleValueContainer() + if singleValueContainer.decodeNil() { + #if DEBUG + self.init(wrappedValue: [], outcome: .valueWasNil) + #else + self.init(wrappedValue: []) + #endif + return } + } catch { + // Not a single value container, proceed with array decoding } - self.wrappedValue = elements + do { + var container = try decoder.unkeyedContainer() + + var elements: [T] = [] + #if DEBUG + var results: [Result] = [] + #endif + + while !container.isAtEnd { + let elementDecoder = try container.superDecoder() + do { + let value = try elementDecoder.singleValueContainer().decode(T.self) + elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif + } catch { + elementDecoder.reportError(error) + #if DEBUG + results.append(.failure(error)) + #endif + } + } + + #if DEBUG + if elements.count == results.count { + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + } else { + let error = ResilientDecodingOutcome.ArrayDecodingError(results: results) + self.init(wrappedValue: elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + self.init(wrappedValue: elements) + #endif + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: []) + #endif + } } } @@ -47,6 +103,16 @@ extension LossyArray: Encodable where T: Encodable { } } -extension LossyArray: Equatable where T: Equatable {} -extension LossyArray: Hashable where T: Hashable {} +extension LossyArray: Equatable where T: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LossyArray: Hashable where T: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LossyArray: Sendable where T: Sendable {} diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift index 8fbc209..5ad98bf 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyDictionary.swift @@ -16,9 +16,23 @@ import Foundation public struct LossyDictionary { public var wrappedValue: [Key: Value] + public let outcome: ResilientDecodingOutcome + public init(wrappedValue: [Key: Value]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: [Key: Value], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome } + + #if DEBUG + public var projectedValue: ResilientDictionaryProjectedValue { + ResilientDictionaryProjectedValue(outcome: outcome) + } + #endif } extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { @@ -47,59 +61,182 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { } } - public init(from decoder: Decoder) throws { + private struct ExtractedKey { + let codingKey: DictionaryCodingKey + let originalKey: String + } + + private struct DecodingState { var elements: [Key: Value] = [:] - if Key.self == String.self { - let container = try decoder.container(keyedBy: DictionaryCodingKey.self) - let keys = try Self.extractKeys(from: decoder, container: container) - - for (key, stringKey) in keys { - do { - let value = try container.decode(LossyDecodableValue.self, forKey: key).value - elements[stringKey as! Key] = value - } catch { - _ = try? container.decode(AnyDecodableValue.self, forKey: key) - } - } - } else if Key.self == Int.self { - let container = try decoder.container(keyedBy: DictionaryCodingKey.self) - - for key in container.allKeys { - guard key.intValue != nil else { - var codingPath = decoder.codingPath - codingPath.append(key) - throw DecodingError.typeMismatch( - Int.self, - DecodingError.Context( - codingPath: codingPath, - debugDescription: "Expected Int key but found String key instead." - ) - ) - } - - do { - let value = try container.decode(LossyDecodableValue.self, forKey: key).value - elements[key.intValue! as! Key] = value - } catch { - _ = try? container.decode(AnyDecodableValue.self, forKey: key) - } + #if DEBUG + var results: [Key: Result] = [:] + #endif + } + + private static func decodeNilValue(from decoder: Decoder) -> Bool { + do { + let singleValueContainer = try decoder.singleValueContainer() + return singleValueContainer.decodeNil() + } catch { + return false + } + } + + private static func decodeStringKeyedDictionary( + from decoder: Decoder + ) throws -> DecodingState { + guard Key.self == String.self else { + fatalError("This method should only be called for String keys") + } + + var state = DecodingState() + let container = try decoder.container(keyedBy: DictionaryCodingKey.self) + let keys = try extractKeys(from: decoder, container: container) + + for extractedKey in keys { + decodeSingleKeyValue( + container: container, + key: extractedKey.codingKey, + originalKey: extractedKey.originalKey, + state: &state + ) + } + + return state + } + + private static func decodeIntKeyedDictionary( + from decoder: Decoder + ) throws -> DecodingState { + guard Key.self == Int.self else { + fatalError("This method should only be called for Int keys") + } + + var state = DecodingState() + let container = try decoder.container(keyedBy: DictionaryCodingKey.self) + + for key in container.allKeys { + guard let intValue = key.intValue else { + // Skip non-integer keys instead of throwing + continue } - } else { - throw DecodingError.dataCorrupted( - DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Unable to decode key type." - ) + + decodeSingleKeyValueForInt( + container: container, + key: key, + intKey: intValue, + state: &state ) } - self.wrappedValue = elements + return state + } + + private static func decodeSingleKeyValue( + container: KeyedDecodingContainer, + key: DictionaryCodingKey, + originalKey: String, + state: inout DecodingState + ) { + // Safe casting - if it fails, we skip this key entirely + guard let castKey = originalKey as? Key else { return } + + do { + let value = try container.decode(LossyDecodableValue.self, forKey: key).value + state.elements[castKey] = value + #if DEBUG + state.results[castKey] = .success(value) + #endif + } catch { + _ = try? container.decode(AnyDecodableValue.self, forKey: key) + let decoder = try? container.superDecoder(forKey: key) + decoder?.reportError(error) + #if DEBUG + state.results[castKey] = .failure(error) + #endif + } + } + + private static func decodeSingleKeyValueForInt( + container: KeyedDecodingContainer, + key: DictionaryCodingKey, + intKey: Int, + state: inout DecodingState + ) { + // Safe casting - if it fails, we skip this key entirely + guard let castKey = intKey as? Key else { return } + + do { + let value = try container.decode(LossyDecodableValue.self, forKey: key).value + state.elements[castKey] = value + #if DEBUG + state.results[castKey] = .success(value) + #endif + } catch { + _ = try? container.decode(AnyDecodableValue.self, forKey: key) + let decoder = try? container.superDecoder(forKey: key) + decoder?.reportError(error) + #if DEBUG + state.results[castKey] = .failure(error) + #endif + } + } + + private static func createFinalResult(from state: DecodingState) -> LossyDictionary { + #if DEBUG + if state.elements.count == state.results.count { + return LossyDictionary(wrappedValue: state.elements, outcome: .decodedSuccessfully) + } else { + let error = ResilientDecodingOutcome.DictionaryDecodingError(results: state.results) + return LossyDictionary(wrappedValue: state.elements, outcome: .recoveredFrom(error, wasReported: false)) + } + #else + return LossyDictionary(wrappedValue: state.elements) + #endif + } + + public init(from decoder: Decoder) throws { + // Check for nil first + if Self.decodeNilValue(from: decoder) { + #if DEBUG + self.init(wrappedValue: [:], outcome: .valueWasNil) + #else + self.init(wrappedValue: [:]) + #endif + return + } + + do { + let state: DecodingState + + if Key.self == String.self { + state = try Self.decodeStringKeyedDictionary(from: decoder) + } else if Key.self == Int.self { + state = try Self.decodeIntKeyedDictionary(from: decoder) + } else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Unable to decode key type." + ) + ) + } + + self = Self.createFinalResult(from: state) + } catch { + decoder.reportError(error) + #if DEBUG + self.init(wrappedValue: [:], outcome: .recoveredFrom(error, wasReported: true)) + #else + self.init(wrappedValue: [:]) + #endif + } } private static func extractKeys( from decoder: Decoder, container: KeyedDecodingContainer - ) throws -> [(DictionaryCodingKey, String)] { + ) throws -> [ExtractedKey] { // Decode a dictionary ignoring the values to decode the original keys // without using the `JSONDecoder.KeyDecodingStrategy`. let keys = try decoder.singleValueContainer().decode([String: AnyDecodableValue].self).keys @@ -108,7 +245,7 @@ extension LossyDictionary: Decodable where Key: Decodable, Value: Decodable { container.allKeys.sorted(by: { $0.stringValue < $1.stringValue }), keys.sorted() ) - .map { ($0, $1) } + .map { ExtractedKey(codingKey: $0, originalKey: $1) } } } @@ -118,5 +255,31 @@ extension LossyDictionary: Encodable where Key: Encodable, Value: Encodable { } } -extension LossyDictionary: Equatable where Value: Equatable {} +extension LossyDictionary: Equatable where Value: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + extension LossyDictionary: Sendable where Key: Sendable, Value: Sendable {} + +// MARK: - KeyedDecodingContainer + +extension KeyedDecodingContainer { + public func decode( + _: LossyDictionary.Type, + forKey key: Key + ) throws -> LossyDictionary + where DictKey: Hashable & Decodable, DictValue: Decodable + { + if let value = try decodeIfPresent(LossyDictionary.self, forKey: key) { + return value + } else { + #if DEBUG + return LossyDictionary(wrappedValue: [:], outcome: .keyNotFound) + #else + return LossyDictionary(wrappedValue: [:]) + #endif + } + } +} diff --git a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift index 278ca9e..0f7bf61 100644 --- a/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift +++ b/Sources/KarrotCodableKit/BetterCodable/LossyValue/LossyOptional.swift @@ -5,7 +5,6 @@ // Created by Elon on 4/9/25. // - import Foundation public struct DefaultNilStrategy: DefaultCodableStrategy { diff --git a/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift new file mode 100644 index 0000000..1581f06 --- /dev/null +++ b/Sources/KarrotCodableKit/BetterCodable/ResilientProjectedValue.swift @@ -0,0 +1,78 @@ +// +// ResilientProjectedValue.swift +// KarrotCodableKit +// +// Created by Elon on 4/9/25. +// + +import Foundation + +#if DEBUG +/// Common protocol for all resilient projected values. +public protocol ResilientProjectedValueProtocol { + var outcome: ResilientDecodingOutcome { get } + var error: Error? { get } +} + +/// Default implementation for error extraction. +extension ResilientProjectedValueProtocol { + public var error: Error? { + switch outcome { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + nil + case .recoveredFrom(let error, _): + error + } + } +} + +/// A base implementation for property wrapper projected values that track decoding outcomes. +/// +/// This struct provides common functionality for all BetterCodable property wrappers' projected values, +/// including error tracking and resilient decoding outcome information. +public struct ResilientProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } +} + +/// A dynamic member lookup extension for array-based property wrappers. +/// +/// This struct extends the base projected value with dynamic member lookup capabilities +/// for accessing detailed array decoding errors. +@dynamicMemberLookup +public struct ResilientArrayProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } + + public subscript( + dynamicMember keyPath: KeyPath, U> + ) -> U { + outcome.arrayDecodingError()[keyPath: keyPath] + } +} + +/// A dynamic member lookup extension for dictionary-based property wrappers. +/// +/// This struct extends the base projected value with dynamic member lookup capabilities +/// for accessing detailed dictionary decoding errors. +@dynamicMemberLookup +public struct ResilientDictionaryProjectedValue: ResilientProjectedValueProtocol { + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } + + public subscript( + dynamicMember keyPath: KeyPath, U> + ) -> U { + outcome.dictionaryDecodingError()[keyPath: keyPath] + } +} +#endif diff --git a/Sources/KarrotCodableKit/Encodable+ToDictionary.swift b/Sources/KarrotCodableKit/Encodable+ToDictionary.swift index d48c7ce..c1b358c 100644 --- a/Sources/KarrotCodableKit/Encodable+ToDictionary.swift +++ b/Sources/KarrotCodableKit/Encodable+ToDictionary.swift @@ -19,10 +19,11 @@ extension Encodable { */ public func toDictionary() throws -> [String: Any] { let data = try JSONEncoder().encode(self) - guard let dictionary = try JSONSerialization.jsonObject( - with: data, - options: .fragmentsAllowed - ) as? [String: Any] + guard + let dictionary = try JSONSerialization.jsonObject( + with: data, + options: .fragmentsAllowed + ) as? [String: Any] else { throw NSError(domain: "JSONSerialization Failed", code: 0) } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift index 9318286..2b89fda 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/DefaultEmptyPolymorphicArrayValue.swift @@ -31,26 +31,50 @@ public struct DefaultEmptyPolymorphicArrayValue.self).wrappedValue elements.append(value) } self.wrappedValue = elements + self.outcome = .decodedSuccessfully } catch { - print("`DefaultEmptyPolymorphicArrayValue` decode catch error: \(error)") + // Report error to error reporter + decoder.reportError(error) self.wrappedValue = [] + self.outcome = .recoveredFrom(error, wasReported: true) } } } @@ -64,6 +88,16 @@ extension DefaultEmptyPolymorphicArrayValue: Encodable { } } -extension DefaultEmptyPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension DefaultEmptyPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension DefaultEmptyPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension DefaultEmptyPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension DefaultEmptyPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift index 4358826..25d1cf7 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+DefaultEmptyPolymorphicArrayValue.swift @@ -13,14 +13,37 @@ extension KeyedDecodingContainer { _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Key ) throws -> DefaultEmptyPolymorphicArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? DefaultEmptyPolymorphicArrayValue(wrappedValue: []) + // Check if key exists + guard contains(key) else { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .keyNotFound) + } + + // Check if value is null + if try decodeNil(forKey: key) { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) + } + + // Try to decode using the property wrapper's decoder + let decoder = try superDecoder(forKey: key) + return try DefaultEmptyPolymorphicArrayValue(from: decoder) } public func decodeIfPresent( _ type: DefaultEmptyPolymorphicArrayValue.Type, forKey key: Self.Key - ) throws -> DefaultEmptyPolymorphicArrayValue where T.ExpectedType: Decodable { - let optionalArrayValue = try decodeIfPresent([T.ExpectedType].self, forKey: key) - return DefaultEmptyPolymorphicArrayValue(wrappedValue: optionalArrayValue ?? []) + ) throws -> DefaultEmptyPolymorphicArrayValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return DefaultEmptyPolymorphicArrayValue(wrappedValue: [], outcome: .valueWasNil) + } + + // Try to decode using the property wrapper's decoder + let decoder = try superDecoder(forKey: key) + return try DefaultEmptyPolymorphicArrayValue(from: decoder) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift index 3a1dd80..ec34083 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+LossyOptionalPolymorphicValue.swift @@ -13,14 +13,37 @@ extension KeyedDecodingContainer { _ type: LossyOptionalPolymorphicValue.Type, forKey key: Key ) throws -> LossyOptionalPolymorphicValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? LossyOptionalPolymorphicValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + value + } else { + LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + } } public func decodeIfPresent( _ type: LossyOptionalPolymorphicValue.Type, forKey key: Self.Key - ) throws -> LossyOptionalPolymorphicValue where T.ExpectedType: Decodable { - let optionalValue = try decodeIfPresent(T.ExpectedType.self, forKey: key) - return LossyOptionalPolymorphicValue(wrappedValue: optionalValue) + ) throws -> LossyOptionalPolymorphicValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) + } + + // Try to decode the polymorphic value + do { + let decoder = try superDecoder(forKey: key) + let value = try T.decode(from: decoder) + return LossyOptionalPolymorphicValue(wrappedValue: value, outcome: .decodedSuccessfully) + } catch { + // Report error to resilient decoding error reporter + let decoder = try? superDecoder(forKey: key) + decoder?.reportError(error) + return LossyOptionalPolymorphicValue(wrappedValue: nil, outcome: .recoveredFrom(error, wasReported: true)) + } } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift index 9fe256a..70b3e5b 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicArrayValue.swift @@ -13,7 +13,15 @@ extension KeyedDecodingContainer { _ type: OptionalPolymorphicArrayValue.Type, forKey key: Key ) throws -> OptionalPolymorphicArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? OptionalPolymorphicArrayValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + return value + } else { + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: nil, outcome: .keyNotFound) + #else + return OptionalPolymorphicArrayValue(wrappedValue: nil) + #endif + } } public func decodeIfPresent( @@ -22,24 +30,39 @@ extension KeyedDecodingContainer { ) throws -> OptionalPolymorphicArrayValue? where T: PolymorphicCodableStrategy { // Check if the key exists guard contains(key) else { - return OptionalPolymorphicArrayValue(wrappedValue: nil) + return nil } - + // Check if the value is null if try decodeNil(forKey: key) { + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: nil, outcome: .valueWasNil) + #else return OptionalPolymorphicArrayValue(wrappedValue: nil) + #endif } - - // Decode the array - var container = try nestedUnkeyedContainer(forKey: key) - var elements = [T.ExpectedType]() - - while !container.isAtEnd { - // Use PolymorphicValue for decoding each element - let value = try container.decode(PolymorphicValue.self) - elements.append(value.wrappedValue) + + // Try to decode the array + do { + var container = try nestedUnkeyedContainer(forKey: key) + var elements = [T.ExpectedType]() + + while !container.isAtEnd { + // Use PolymorphicValue for decoding each element + let value = try container.decode(PolymorphicValue.self) + elements.append(value.wrappedValue) + } + + #if DEBUG + return OptionalPolymorphicArrayValue(wrappedValue: elements, outcome: .decodedSuccessfully) + #else + return OptionalPolymorphicArrayValue(wrappedValue: elements) + #endif + } catch { + // Report the error through superDecoder + let decoder = try superDecoder(forKey: key) + decoder.reportError(error) + throw error } - - return OptionalPolymorphicArrayValue(wrappedValue: elements) } -} \ No newline at end of file +} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift index 2bdf143..6f513f8 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+OptionalPolymorphicValue.swift @@ -13,14 +13,35 @@ extension KeyedDecodingContainer { _ type: OptionalPolymorphicValue.Type, forKey key: Key ) throws -> OptionalPolymorphicValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? OptionalPolymorphicValue(wrappedValue: nil) + if let value = try decodeIfPresent(type, forKey: key) { + value + } else { + OptionalPolymorphicValue(wrappedValue: nil, outcome: .keyNotFound) + } } public func decodeIfPresent( _ type: OptionalPolymorphicValue.Type, forKey key: Self.Key - ) throws -> OptionalPolymorphicValue where T.ExpectedType: Decodable { - let optionalValue = try decodeIfPresent(T.ExpectedType.self, forKey: key) - return OptionalPolymorphicValue(wrappedValue: optionalValue) + ) throws -> OptionalPolymorphicValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + return OptionalPolymorphicValue(wrappedValue: nil, outcome: .valueWasNil) + } + + // Try to decode the polymorphic value + do { + let decoder = try superDecoder(forKey: key) + let value = try T.decode(from: decoder) + return OptionalPolymorphicValue(wrappedValue: value, outcome: .decodedSuccessfully) + } catch { + // OptionalPolymorphicValue throws errors instead of recovering + throw error + } } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift index 51dd79a..c826dd6 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Extensions/KeyedDecodingContainer+PolymorphicLossyArrayValue.swift @@ -13,14 +13,62 @@ extension KeyedDecodingContainer { _ type: PolymorphicLossyArrayValue.Type, forKey key: Key ) throws -> PolymorphicLossyArrayValue where T: PolymorphicCodableStrategy { - try decodeIfPresent(type, forKey: key) ?? PolymorphicLossyArrayValue(wrappedValue: []) + // Return empty array if key is missing + guard contains(key) else { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .keyNotFound, results: []) + #else + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .keyNotFound) + #endif + } + + // Check if value is null + if try decodeNil(forKey: key) { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil, results: []) + #else + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil) + #endif + } + + // Try to decode the array + do { + let decoder = try superDecoder(forKey: key) + return try PolymorphicLossyArrayValue(from: decoder) + } catch { + // If decoding fails (e.g., not an array), return empty array + #if DEBUG + return PolymorphicLossyArrayValue( + wrappedValue: [], + outcome: .recoveredFrom(error, wasReported: false), + results: [] + ) + #else + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .recoveredFrom(error, wasReported: false)) + #endif + } } public func decodeIfPresent( _ type: PolymorphicLossyArrayValue.Type, forKey key: Self.Key - ) throws -> PolymorphicLossyArrayValue where T.ExpectedType: Decodable { - let optionalArrayValue = try decodeIfPresent([T.ExpectedType].self, forKey: key) - return PolymorphicLossyArrayValue(wrappedValue: optionalArrayValue ?? []) + ) throws -> PolymorphicLossyArrayValue? where T: PolymorphicCodableStrategy { + // Check if key exists + guard contains(key) else { + return nil + } + + // Check if value is null + if try decodeNil(forKey: key) { + #if DEBUG + return PolymorphicLossyArrayValue(wrappedValue: [], outcome: .valueWasNil, results: []) + #else + return PolymorphicLossyArrayValue(wrappedValue: []) + #endif + } + + // Try to decode using PolymorphicLossyArrayValue's decoder + let decoder = try superDecoder(forKey: key) + return try PolymorphicLossyArrayValue(from: decoder) } } diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift b/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift index 269a5d9..e69c323 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/Interface/PolymorphicCodableStrategyProviding.swift @@ -13,10 +13,12 @@ import Foundation This macro generates a type-safe decoding strategy for polymorphic types based on an identifier field. It creates a strategy that can be used with different polymorphic decodable types - to handle type-based deserialization. + to handle type-based deserialization. This functionality provides Swift implementation for the + OpenAPI Specification's `oneOf` pattern, enabling type-safe polymorphic decoding. - Parameters: - identifierCodingKey: The key name in the JSON used to determine the concrete type. + This corresponds to the `discriminator.propertyName` in OpenAPI Specification's oneOf definition. The default value for this property is `"type"`. - matchingTypes: An array of polymorphic types that this strategy will handle. - fallbackType: Optional type to use when no matching type is found. If nil, decoding will fail diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift index d61d956..50bb252 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/LossyOptionalPolymorphicValue.swift @@ -29,9 +29,30 @@ public struct LossyOptionalPolymorphicValue Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension LossyOptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension LossyOptionalPolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift index 6bc968a..8a35a02 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicArrayValue.swift @@ -38,31 +38,66 @@ public struct OptionalPolymorphicArrayValue.self) + let value = try unkeyedContainer.decode(PolymorphicValue.self) elements.append(value.wrappedValue) } - - self.wrappedValue = elements - } else { - // If we can't get an unkeyed container, the value is either nil, missing, or not an array - // Set wrappedValue to nil without throwing an error - self.wrappedValue = nil + + // Successfully decoded the array + #if DEBUG + self.init(wrappedValue: elements, outcome: .decodedSuccessfully) + #else + self.init(wrappedValue: elements) + #endif + } catch { + // Report the error and re-throw it + decoder.reportError(error) + throw error } } } @@ -83,6 +118,16 @@ extension OptionalPolymorphicArrayValue: Encodable { } } -extension OptionalPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension OptionalPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} -extension OptionalPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} \ No newline at end of file +extension OptionalPolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension OptionalPolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + +extension OptionalPolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift index 72634f7..0ad6462 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/OptionalPolymorphicValue.swift @@ -26,9 +26,30 @@ public struct OptionalPolymorphicValue Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension OptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) } } -extension OptionalPolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension OptionalPolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable {} extension OptionalPolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift index 2915aec..27be64b 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicArrayValue.swift @@ -20,10 +20,31 @@ public struct PolymorphicArrayValue /// The decoded array of values, each conforming to the expected polymorphic type. public var wrappedValue: [PolymorphicType.ExpectedType] + /// Tracks the outcome of the decoding process for resilient decoding + public let outcome: ResilientDecodingOutcome + /// Initializes the property wrapper with a pre-decoded array of values. public init(wrappedValue: [PolymorphicType.ExpectedType]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicProjectedValue { + PolymorphicProjectedValue(outcome: outcome) + } + #else + /// In non-DEBUG builds, accessing projectedValue is a programmer error + public var projectedValue: Never { + fatalError("@\(Self.self) projectedValue should not be used in non-DEBUG builds") } + #endif } extension PolymorphicArrayValue: Decodable { @@ -37,6 +58,7 @@ extension PolymorphicArrayValue: Decodable { } self.wrappedValue = elements + self.outcome = .decodedSuccessfully } } @@ -49,6 +71,16 @@ extension PolymorphicArrayValue: Encodable { } } -extension PolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension PolymorphicArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension PolymorphicArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift index 342c028..f203abd 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicLossyArrayValue.swift @@ -10,7 +10,7 @@ import Foundation @available(*, deprecated, renamed: "PolymorphicLossyArrayValue") public typealias DefaultEmptyPolymorphicLossyArrayValue = -PolymorphicLossyArrayValue + PolymorphicLossyArrayValue /// A property wrapper that decodes an array of polymorphic objects with lossy behavior for individual elements, /// and defaults to an empty array `[]` if the array key is missing, the value is `null`, or not a valid JSON array. @@ -40,9 +40,50 @@ public struct PolymorphicLossyArrayValue] + #endif + public init(wrappedValue: [PolymorphicType.ExpectedType]) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + #if DEBUG + self.results = [] + #endif + } + + #if DEBUG + init( + wrappedValue: [PolymorphicType.ExpectedType], + outcome: ResilientDecodingOutcome, + results: [Result] = [] + ) { + self.wrappedValue = wrappedValue + self.outcome = outcome + self.results = results + } + #else + init(wrappedValue: [PolymorphicType.ExpectedType], outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome + } + #endif + + #if DEBUG + /// The projected value providing access to decoding outcome + public var projectedValue: PolymorphicLossyArrayProjectedValue { + PolymorphicLossyArrayProjectedValue(outcome: outcome, results: results) } + #else + /// In non-DEBUG builds, accessing projectedValue is a programmer error + public var projectedValue: Never { + fatalError("@\(Self.self) projectedValue should not be used in non-DEBUG builds") + } + #endif } extension PolymorphicLossyArrayValue: Decodable { @@ -51,20 +92,32 @@ extension PolymorphicLossyArrayValue: Decodable { public init(from decoder: Decoder) throws { var container = try decoder.unkeyedContainer() - var elements = [PolymorphicType.ExpectedType?]() + var elements = [PolymorphicType.ExpectedType]() + #if DEBUG + var results = [Result]() + #endif + while !container.isAtEnd { do { let value = try container.decode(PolymorphicValue.self).wrappedValue elements.append(value) + #if DEBUG + results.append(.success(value)) + #endif } catch { - print("`PolymorphicLossyArrayValue` decode catch error: \(error)") - // Decoding processing to prevent infinite loops if decoding fails. _ = try? container.decode(AnyDecodableValue.self) + #if DEBUG + results.append(.failure(error)) + #endif } } - wrappedValue = elements.compactMap { $0 } + self.wrappedValue = elements + self.outcome = .decodedSuccessfully + #if DEBUG + self.results = results + #endif } } @@ -77,6 +130,16 @@ extension PolymorphicLossyArrayValue: Encodable { } } -extension PolymorphicLossyArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicLossyArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable {} +extension PolymorphicLossyArrayValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicLossyArrayValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) + } +} + extension PolymorphicLossyArrayValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift new file mode 100644 index 0000000..77db26c --- /dev/null +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicProjectedValue.swift @@ -0,0 +1,59 @@ +// +// PolymorphicProjectedValue.swift +// KarrotCodableKit +// +// Created by Elon on 2025-07-23. +// Copyright © 2025 Danggeun Market Inc. All rights reserved. +// + +import Foundation + +#if DEBUG +/// Common protocol for polymorphic projected values. +public protocol PolymorphicProjectedValueProtocol { + var outcome: ResilientDecodingOutcome { get } + var error: Error? { get } +} + +/// Default implementation for error extraction. +extension PolymorphicProjectedValueProtocol { + public var error: Error? { + switch outcome { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + nil + case .recoveredFrom(let error, _): + error + } + } +} + +/// A generic projected value for polymorphic property wrappers. +/// +/// This struct provides common functionality for all polymorphic property wrappers' projected values, +/// including error tracking and resilient decoding outcome information. +public struct PolymorphicProjectedValue: PolymorphicProjectedValueProtocol { + /// The outcome of the decoding process + public let outcome: ResilientDecodingOutcome + + public init(outcome: ResilientDecodingOutcome) { + self.outcome = outcome + } +} + +/// A specialized projected value for lossy array property wrappers that includes element-level results. +/// +/// This struct extends the base projected value with additional information about individual +/// element decoding results for lossy array operations. +public struct PolymorphicLossyArrayProjectedValue: PolymorphicProjectedValueProtocol { + /// The outcome of the decoding process + public let outcome: ResilientDecodingOutcome + + /// Results of decoding each element in the array + public let results: [Result] + + public init(outcome: ResilientDecodingOutcome, results: [Result]) { + self.outcome = outcome + self.results = results + } +} +#endif diff --git a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift index 4c8e03e..ec38d56 100644 --- a/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift +++ b/Sources/KarrotCodableKit/PolymorphicCodable/PolymorphicValue.swift @@ -24,10 +24,22 @@ public struct PolymorphicValue { /// The decoded value of the expected polymorphic type. public var wrappedValue: PolymorphicType.ExpectedType + public let outcome: ResilientDecodingOutcome + /// Initializes the property wrapper with a pre-decoded value. public init(wrappedValue: PolymorphicType.ExpectedType) { self.wrappedValue = wrappedValue + self.outcome = .decodedSuccessfully + } + + init(wrappedValue: PolymorphicType.ExpectedType, outcome: ResilientDecodingOutcome) { + self.wrappedValue = wrappedValue + self.outcome = outcome } + + #if DEBUG + public var projectedValue: PolymorphicProjectedValue { PolymorphicProjectedValue(outcome: outcome) } + #endif } extension PolymorphicValue: Encodable { @@ -38,10 +50,26 @@ extension PolymorphicValue: Encodable { extension PolymorphicValue: Decodable { public init(from decoder: Decoder) throws { - self.wrappedValue = try PolymorphicType.decode(from: decoder) + do { + self.wrappedValue = try PolymorphicType.decode(from: decoder) + self.outcome = .decodedSuccessfully + } catch { + decoder.reportError(error) + throw error + } + } +} + +extension PolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable { + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.wrappedValue == rhs.wrappedValue + } +} + +extension PolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable { + public func hash(into hasher: inout Hasher) { + hasher.combine(wrappedValue) } } -extension PolymorphicValue: Equatable where PolymorphicType.ExpectedType: Equatable {} -extension PolymorphicValue: Hashable where PolymorphicType.ExpectedType: Hashable {} extension PolymorphicValue: Sendable where PolymorphicType.ExpectedType: Sendable {} diff --git a/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift new file mode 100644 index 0000000..214cba0 --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ArrayDecodingError.swift @@ -0,0 +1,42 @@ +// +// ArrayDecodingError.swift +// KarrotCodableKit +// +// Created by Elon on 7/28/25. +// + +import Foundation + +#if DEBUG +extension ResilientDecodingOutcome { + /// A type representing some number of errors encountered while decoding an array + public struct ArrayDecodingError: Error { + public let results: [Result] + public var errors: [Error] { + results.compactMap(\.failure) + } + + public init(results: [Result]) { + self.results = results + } + } + + func arrayDecodingError() -> ResilientDecodingOutcome.ArrayDecodingError { + typealias ArrayDecodingError = ResilientDecodingOutcome.ArrayDecodingError + switch self { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return .init(results: []) + + case .recoveredFrom(let error as ArrayDecodingError, let wasReported): + /// `ArrayDecodingError` should not be reported + assert(!wasReported) + return error + + case .recoveredFrom(let error, _): + /// 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. + return .init(results: [.failure(error)]) + } + } +} +#endif diff --git a/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift new file mode 100644 index 0000000..3b9928f --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/DictionaryDecodingError.swift @@ -0,0 +1,40 @@ +// +// DictionaryDecodingError.swift +// KarrotCodableKit +// +// Created by Elon on 7/28/25. +// + +import Foundation + +#if DEBUG +extension ResilientDecodingOutcome { + public struct DictionaryDecodingError: Error { + public let results: [Key: Result] + public var errors: [Key: Error] { + results.compactMapValues(\.failure) + } + + public init(results: [Key: Result]) { + self.results = results + } + } + + func dictionaryDecodingError() -> ResilientDecodingOutcome.DictionaryDecodingError { + typealias DictionaryDecodingError = ResilientDecodingOutcome.DictionaryDecodingError + switch self { + case .decodedSuccessfully, .keyNotFound, .valueWasNil: + return .init(results: [:]) + + case .recoveredFrom(let error as DictionaryDecodingError, let wasReported): + /// `DictionaryDecodingError` should not be reported + assert(!wasReported) + return error + + case .recoveredFrom: + /// 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. + return .init(results: [:]) + } + } +} +#endif diff --git a/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift new file mode 100644 index 0000000..580decb --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ErrorReporting.swift @@ -0,0 +1,238 @@ +// +// ErrorReporting.swift +// KarrotCodableKit +// +// Created by Elon on 7/28/25. +// + +import Foundation + +// MARK: - Enabling Error Reporting + +extension CodingUserInfoKey { + public static let resilientDecodingErrorReporter = CodingUserInfoKey( + rawValue: "ResilientDecodingErrorReporter" + )! +} + +extension [CodingUserInfoKey: Any] { + + /// 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. + /// - note: May only be called once on a particular `userInfo` dictionary + public mutating func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { + let errorReporter = ResilientDecodingErrorReporter() + replaceResilientDecodingErrorReporter(with: errorReporter) + return errorReporter + } + + /// Replaces the existing error reporter with the provided one + /// - returns: The previous value of the `resilientDecodingErrorReporter` key, which can be used to restore this dictionary to its original state. + @discardableResult + fileprivate mutating func replaceResilientDecodingErrorReporter( + with errorReporter: ResilientDecodingErrorReporter + ) -> Any? { + if let existingValue = self[.resilientDecodingErrorReporter] { + assertionFailure() + if let existingReporter = existingValue as? ResilientDecodingErrorReporter { + existingReporter.currentDigest.mayBeMissingReportedErrors = true + } + } + + self[.resilientDecodingErrorReporter] = errorReporter + return errorReporter + } +} + +extension JSONDecoder { + + /// Creates and registers a `ResilientDecodingErrorReporter` with this `JSONDecoder`. + /// Any `Resilient` properties which this `JSONDecoder` decodes will report their errors to the returned error reporter. + /// - note: May only be called once per `JSONDecoder` + public func enableResilientDecodingErrorReporting() -> ResilientDecodingErrorReporter { + userInfo.enableResilientDecodingErrorReporting() + } + + public func decode( + _ type: T.Type, + from data: Data, + reportResilientDecodingErrors: Bool + ) throws -> (T, ErrorDigest?) { + guard reportResilientDecodingErrors else { + return (try decode(T.self, from: data), nil) + } + + let errorReporter = ResilientDecodingErrorReporter() + let oldValue = userInfo.replaceResilientDecodingErrorReporter(with: errorReporter) + let value = try decode(T.self, from: data) + userInfo[.resilientDecodingErrorReporter] = oldValue + + return (value, errorReporter.flushReportedErrors()) + } +} + +// MARK: - Accessing Reported Errors + +public final class ResilientDecodingErrorReporter { + + /// Creates a `ResilientDecodingErrorReporter`, which is only useful + /// if it is the value for the key `resilientDecodingErrorReporter` in a `Decoder`'s `userInfo` + public init() {} + + /// This is meant to be called immediately after decoding a `Decodable` type from a `Decoder`. + /// - returns: Any errors encountered up to this point in time + public func flushReportedErrors() -> ErrorDigest? { + let digest = hasErrors ? currentDigest : nil + hasErrors = false + currentDigest = ErrorDigest() + return digest + } + + /// This should only ever be called by `Decoder.resilientDecodingHandled` when an error is handled, + /// consider calling that method instead. + /// It is `internal` and not `fileprivate` only to allow us to split the up the two files. + func resilientDecodingHandled(_ error: Error, at path: [String]) { + hasErrors = true + currentDigest.root.insert(error, at: path) + } + + fileprivate var currentDigest = ErrorDigest() + private var hasErrors = false +} + +public struct ErrorDigest { + + public var errors: [Error] { + errors(includeUnknownNovelValueErrors: false) + } + + public func errors(includeUnknownNovelValueErrors: Bool) -> [Error] { + let allErrors: [Error] = if mayBeMissingReportedErrors { + [MayBeMissingReportedErrors()] + root.errors + } else { + root.errors + } + + return allErrors.filter { includeUnknownNovelValueErrors || !($0 is UnknownNovelValueError) } + } + + /// 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. + fileprivate var mayBeMissingReportedErrors = false + + fileprivate struct Node { + private var children: [String: Node] = [:] + private var shallowErrors: [Error] = [] + + /// Inserts an error at the provided path + mutating func insert(_ error: Error, at path: some Collection) { + if let next = path.first { + children[next, default: Node()].insert(error, at: path.dropFirst()) + } else { + shallowErrors.append(error) + } + } + + var errors: [Error] { + shallowErrors + children.flatMap { $0.value.errors } + } + } + + fileprivate var root = Node() +} + +// MARK: - Reporting Errors + +extension Decoder { + + /// 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. + public func reportError(_ error: Swift.Error) { + guard let errorReporterAny = userInfo[.resilientDecodingErrorReporter] else { + return + } + + /// Check that we haven't hit the very unlikely case where someone has overriden our user info key with something we do not expect. + guard let errorReporter = errorReporterAny as? ResilientDecodingErrorReporter else { + assertionFailure() + return + } + + errorReporter.resilientDecodingHandled(error, at: codingPath.map { $0.stringValue }) + } +} + +// MARK: - Pretty Printing + +#if DEBUG + +extension ErrorDigest: CustomDebugStringConvertible { + public var debugDescription: String { + root.debugDescriptionLines.joined(separator: "\n") + } +} + +extension ErrorDigest.Node { + var debugDescriptionLines: [String] { + let errorLines = shallowErrors.map { "- " + $0.abridgedDescription }.sorted() + let childrenLines = children + .sorted(by: { $0.key < $1.key }) + .flatMap { child in + [child.key] + child.value.debugDescriptionLines.map { " " + $0 } + } + + return errorLines + childrenLines + } +} + +extension Error { + + /// An abridged description which does not include the coding path + fileprivate var abridgedDescription: String { + switch self { + case let decodingError as DecodingError: + switch decodingError { + case .dataCorrupted: + return "Data corrupted" + case .keyNotFound(let key, _): + return "Key \"\(key.stringValue)\" not found" + case .typeMismatch(let attempted, _): + return "Could not decode as `\(attempted)`" + case .valueNotFound(let attempted, _): + return "Expected `\(attempted)` but found null instead" + @unknown default: + return localizedDescription + } + + case let error as UnknownNovelValueError: + return "Unknown novel value \"\(error.novelValue)\" (this error is not reported by default)" + + default: + return localizedDescription + } + } +} + +#endif + +// MARK: - Specific Errors + +/// 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. +private struct MayBeMissingReportedErrors: Error {} + +/// 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`. +/// 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. +public struct UnknownNovelValueError: Error { + + /// The raw value for which `init(rawValue:)` returned `nil`. + public let novelValue: Any + + /// - parameter novelValue: A value which is believed to be valid but the code does not know how to handle. + public init(novelValue: T) { + self.novelValue = novelValue + } +} diff --git a/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift new file mode 100644 index 0000000..46a484a --- /dev/null +++ b/Sources/KarrotCodableKit/Resilient/ResilientDecodingOutcome.swift @@ -0,0 +1,48 @@ +// +// ResilientDecodingOutcome.swift +// KarrotCodableKit +// +// Created by Elon on 7/28/25. +// + +import Foundation + +#if DEBUG +public enum ResilientDecodingOutcome: Sendable { + /// A value was decoded successfully + case decodedSuccessfully + /// The key was missing, and it was not treated as an error (for instance when decoding an `Optional`) + case keyNotFound + /// The value was `nil`, and it was not treated as an error (for instance when decoding an `Optional`) + case valueWasNil + /// An error was recovered from during decoding + /// - parameter `wasReported`: Some errors are not reported, for instance `ArrayDecodingError` + case recoveredFrom(any Error, wasReported: Bool) +} + +extension ResilientDecodingOutcome: Equatable { + public static func == (lhs: ResilientDecodingOutcome, rhs: ResilientDecodingOutcome) -> Bool { + switch (lhs, rhs) { + case (.decodedSuccessfully, .decodedSuccessfully), + (.keyNotFound, .keyNotFound), + (.valueWasNil, .valueWasNil): + true + case (.recoveredFrom(_, let lhsReported), .recoveredFrom(_, let rhsReported)): + lhsReported == rhsReported + default: + false + } + } +} +#else +/// 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. +public struct ResilientDecodingOutcome: Sendable { + public static let decodedSuccessfully = Self() + public static let keyNotFound = Self() + public static let valueWasNil = Self() + public static let recoveredFromDebugOnlyError = Self() + public static func recoveredFrom(_: any Error, wasReported: Bool) -> Self { Self() } +} +#endif diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift new file mode 100644 index 0000000..331a554 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DataValue/DataValueResilientTests.swift @@ -0,0 +1,118 @@ +// +// DataValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("DataValue Resilient Decoding") +struct DataValueResilientTests { + struct Fixture: Decodable { + @DataValue var base64Data: Data + @DataValue var anotherData: Data + } + + @Test("projected value provides error information") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "base64Data": "SGVsbG8gV29ybGQ=", + "anotherData": "VGVzdCBEYXRh" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior + #expect(String(data: fixture.base64Data, encoding: .utf8) == "Hello World") + #expect(String(data: fixture.anotherData, encoding: .utf8) == "Test Data") + + #if DEBUG + // Access success info through projected value + #expect(fixture.$base64Data.outcome == .decodedSuccessfully) + #expect(fixture.$anotherData.outcome == .decodedSuccessfully) + #endif + } + + @Test("invalid base64 format handling") + func invalidBase64Format() async throws { + let json = """ + { + "base64Data": "Invalid!@#$%^&*()Base64", + "anotherData": "=====" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Invalid Base64 format causes decoding failure + confirmation() + } + } + } + + @Test("null values handling") + func nullValues() async throws { + let json = """ + { + "base64Data": null, + "anotherData": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be converted to Data + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func errorReporting() async throws { + let json = """ + { + "base64Data": 12345, + "anotherData": {"key": "value"} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Type mismatch causes decoding failure + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift new file mode 100644 index 0000000..4d6ab41 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/DateValue/DateValueResilientTests.swift @@ -0,0 +1,125 @@ +// +// DateValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("DateValue Resilient Decoding") +struct DateValueResilientTests { + struct Fixture: Decodable { + @DateValue var isoDate: Date + @DateValue var rfcDate: Date + @DateValue var timestampDate: Date + } + + @Test("projected value provides error information") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "isoDate": "2025-01-01T12:00:00Z", + "rfcDate": "2025-01-01T12:00:00+00:00", + "timestampDate": 1735728000 + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify basic functionality + #expect(fixture.isoDate.timeIntervalSince1970 > 0) + #expect(fixture.rfcDate.timeIntervalSince1970 > 0) + #expect(fixture.timestampDate.timeIntervalSince1970 > 0) + + #if DEBUG + // Access success info via projected value + #expect(fixture.$isoDate.outcome == .decodedSuccessfully) + #expect(fixture.$rfcDate.outcome == .decodedSuccessfully) + #expect(fixture.$timestampDate.outcome == .decodedSuccessfully) + #endif + } + + @Test("invalid date format handling") + func invalidDateFormat() async throws { + let json = """ + { + "isoDate": "invalid-date", + "rfcDate": "2025-01-01", + "timestampDate": "not a number" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Invalid date format causes decoding failure + confirmation() + } + } + } + + @Test("null values handling") + func nullValues() async throws { + let json = """ + { + "isoDate": null, + "rfcDate": null, + "timestampDate": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be converted to Date + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func errorReporting() async throws { + let json = """ + { + "isoDate": 12345, + "rfcDate": true, + "timestampDate": ["array"] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Type mismatch causes decoding failure + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift new file mode 100644 index 0000000..9c324a7 --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableResilientTests.swift @@ -0,0 +1,417 @@ +// +// DefaultCodableResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import KarrotCodableKit +import Testing + +@Suite("DefaultCodable Resilient Decoding") +struct DefaultCodableResilientTests { + struct Fixture: Decodable { + @DefaultZeroInt var intValue: Int + @DefaultEmptyString var stringValue: String + @DefaultFalse var boolValue: Bool + @DefaultEmptyArray var arrayValue: [String] + @DefaultEmptyDictionary var dictValue: [String: Int] + } + + @Test("projected value provides error information for failed decoding") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "intValue": "not a number", + "stringValue": 123, + "boolValue": "not a bool", + "arrayValue": "not an array", + "dictValue": "not a dict" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - use default value on decoding failure + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$intValue.error != nil) + #expect(fixture.$stringValue.error != nil) + #expect(fixture.$boolValue.error != nil) + #expect(fixture.$arrayValue.error != nil) + #expect(fixture.$dictValue.error != nil) + + /// Check error type + let error = try #require(fixture.$intValue.error as? DecodingError) + switch error { + case .typeMismatch: + // Expected + break + default: + Issue.record("Expected typeMismatch error") + } + #endif + } + + @Test("missing keys use default values without error") + func missingKeysUseDefaultValues() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Check default values + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // No error when key is missing (default behavior) + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("valid values decode successfully") + func validValuesDecodeSuccessfully() throws { + let json = """ + { + "intValue": 42, + "stringValue": "hello", + "boolValue": true, + "arrayValue": ["a", "b", "c"], + "dictValue": {"key": 123} + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Check normal values + #expect(fixture.intValue == 42) + #expect(fixture.stringValue == "hello") + #expect(fixture.boolValue == true) + #expect(fixture.arrayValue == ["a", "b", "c"]) + #expect(fixture.dictValue == ["key": 123]) + + #if DEBUG + // No error when successfully decoded + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("null values use default values") + func nullValuesUseDefaultValues() throws { + let json = """ + { + "intValue": null, + "stringValue": null, + "boolValue": null, + "arrayValue": null, + "dictValue": null + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(Fixture.self, from: data) + + // Use default value for null + #expect(fixture.intValue == 0) + #expect(fixture.stringValue == "") + #expect(fixture.boolValue == false) + #expect(fixture.arrayValue == []) + #expect(fixture.dictValue == [:]) + + #if DEBUG + // null is not considered an error + #expect(fixture.$intValue.error == nil) + #expect(fixture.$stringValue.error == nil) + #expect(fixture.$boolValue.error == nil) + #expect(fixture.$arrayValue.error == nil) + #expect(fixture.$dictValue.error == nil) + #endif + } + + @Test("error reporting with JSONDecoder") + func errorReportingWithDecoder() throws { + let json = """ + { + "intValue": "invalid", + "stringValue": [], + "boolValue": {} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = json.data(using: .utf8)! + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + let digest = try #require(errorDigest) + // At least 3 errors should be reported + #expect(digest.errors.count >= 3) + #if DEBUG + print("Error digest: \(digest.debugDescription)") + #endif + } + + @Test("LossyOptional behavior") + func lossyOptional() throws { + struct OptionalFixture: Decodable { + @LossyOptional var url: URL? + @LossyOptional var date: Date? + @LossyOptional var number: Int? + } + + let json = """ + { + "url": "https://example .com", + "date": "not a date", + "number": "not a number" + } + """ + + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(OptionalFixture.self, from: data) + + // nil on decoding failure + #expect(fixture.url == nil) + #expect(fixture.date == nil) + #expect(fixture.number == nil) + + #if DEBUG + // Check error info + #expect(fixture.$url.error != nil) + #expect(fixture.$date.error != nil) + #expect(fixture.$number.error != nil) + #endif + } + + // MARK: - RawRepresentable Support Tests + + enum TestEnum: String, Decodable, DefaultCodableStrategy { + case first + case second + case unknown + + static var defaultValue: TestEnum { .unknown } + } + + enum FrozenTestEnum: String, Decodable, DefaultCodableStrategy { + case alpha + case beta + case fallback + + static var defaultValue: FrozenTestEnum { .fallback } + static var isFrozen: Bool { true } + } + + struct RawRepresentableFixture: Decodable { + @DefaultCodable var normalEnum: TestEnum + @DefaultCodable var frozenEnum: FrozenTestEnum + } + + @Test("RawRepresentable with valid raw values") + func rawRepresentableValidValues() throws { + // given + let json = """ + { + "normalEnum": "first", + "frozenEnum": "alpha" + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .first) + #expect(fixture.frozenEnum == .alpha) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .decodedSuccessfully) + #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with unknown raw values (non-frozen)") + func rawRepresentableUnknownValueNonFrozen() throws { + // given + let json = """ + { + "normalEnum": "third", + "frozenEnum": "beta" + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) // Should use default value + #expect(fixture.frozenEnum == .beta) + + #if DEBUG + // Non-frozen enum should recover with UnknownNovelValueError + if case .recoveredFrom(let error as UnknownNovelValueError, _) = fixture.$normalEnum.outcome { + #expect(error.novelValue as? String == "third") + } else { + Issue.record("Expected recoveredFrom outcome with UnknownNovelValueError") + } + #expect(fixture.$frozenEnum.outcome == .decodedSuccessfully) + #endif + } + + @Test("RawRepresentable with unknown raw values (frozen)") + func rawRepresentableUnknownValueFrozen() throws { + // given + let json = """ + { + "normalEnum": "first", + "frozenEnum": "gamma" + } + """ + + // when + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .first) + #expect(fixture.frozenEnum == .fallback) // Should use default value due to error + + #if DEBUG + // Frozen enum should report DecodingError + if case .recoveredFrom(let error, _) = fixture.$frozenEnum.outcome { + #expect(error is DecodingError) + if case .dataCorrupted = error as? DecodingError { + // Expected + } else { + Issue.record("Expected dataCorrupted DecodingError for frozen enum") + } + } else { + Issue.record("Expected recoveredFrom outcome for frozen enum") + } + + /// Error should be reported to error reporter + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + #endif + } + + @Test("RawRepresentable with missing keys") + func rawRepresentableMissingKeys() throws { + // given + let json = "{}" + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .keyNotFound) + #expect(fixture.$frozenEnum.outcome == .keyNotFound) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with null values") + func rawRepresentableNullValues() throws { + // given + let json = """ + { + "normalEnum": null, + "frozenEnum": null + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + #expect(fixture.$normalEnum.outcome == .valueWasNil) + #expect(fixture.$frozenEnum.outcome == .valueWasNil) + #expect(fixture.$normalEnum.error == nil) + #expect(fixture.$frozenEnum.error == nil) + #endif + } + + @Test("RawRepresentable with type mismatch") + func rawRepresentableTypeMismatch() throws { + // given - enums expect String but we provide numbers + let json = """ + { + "normalEnum": 123, + "frozenEnum": true + } + """ + + // when + let decoder = JSONDecoder() + let data = json.data(using: .utf8)! + let fixture = try decoder.decode(RawRepresentableFixture.self, from: data) + + // then + #expect(fixture.normalEnum == .unknown) + #expect(fixture.frozenEnum == .fallback) + + #if DEBUG + // Both should have type mismatch errors + if case .recoveredFrom(let error, _) = fixture.$normalEnum.outcome { + #expect(error is DecodingError) + if case .typeMismatch = error as? DecodingError { + // Expected + } else { + Issue.record("Expected typeMismatch DecodingError") + } + } else { + Issue.record("Expected recoveredFrom outcome") + } + #endif + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift index f4f5e9a..83692f5 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultCodableTests.swift @@ -59,7 +59,6 @@ final class DefaultCodableTest_DateStrategy: XCTestCase { } } - // MARK: - Nested Property Wrapper final class DefaultCodableTest_NestedPropertyWrapper: XCTestCase { @@ -93,7 +92,6 @@ final class DefaultCodableTest_NestedPropertyWrapper: XCTestCase { } } - // MARK: - Types with Containers final class DefaultCodableTests_TypesWithContainers: XCTestCase { @@ -144,7 +142,6 @@ final class DefaultCodableTests_TypesWithContainers: XCTestCase { } } - // MARK: - Enums with Associated Values final class DefaultCodableTests_EnumWithAssociatedValue: XCTestCase { @@ -180,6 +177,7 @@ final class DefaultCodableTests_EnumWithAssociatedValue: XCTestCase { case .ziz(let i): try c.encode("ziz", forKey: .z) try c.encode(i, forKey: .i) + case .zaz(let i): try c.encode("zaz", forKey: .z) try c.encode(i, forKey: .i) diff --git a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift index d2947a7..9225310 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/Defaults/DefaultEmptyArrayTests.swift @@ -58,4 +58,3 @@ final class DefaultEmptyArrayTests: XCTestCase { XCTAssertEqual(fixture.nonPrimitiveValues, [Fixture.NestedFixture(one: "one", two: ["key": ["value"]])]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift new file mode 100644 index 0000000..cc10f3a --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayResilientTests.swift @@ -0,0 +1,142 @@ +// +// LosslessArrayResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LosslessArray Resilient Decoding") +struct LosslessArrayResilientTests { + struct Fixture: Decodable { + @LosslessArray var stringArray: [String] + @LosslessArray var intArray: [Int] + @LosslessArray var doubleArray: [Double] + + struct NestedObject: Decodable, Equatable, LosslessStringConvertible { + let id: Int + let name: String + + init?(_ description: String) { + nil // Not convertible from string + } + + var description: String { + "NestedObject(id: \(id), name: \(name))" + } + } + + @LosslessArray var objectArray: [String] // Objects cannot be converted to String + } + + @Test("projected value provides error information for each failed element") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringArray": [1, "two", true, null, 5.5], + "intArray": ["invalid", 2, 3.14, 4, true], + "doubleArray": [1.5, "2.5", 3, null, "invalid"], + "objectArray": [{"id": 1, "name": "test"}, "string"] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - only convertible values included + #expect(fixture.stringArray == ["1", "two", "true", "5.5"]) + #expect(fixture.intArray == [2, 4]) + #expect(fixture.doubleArray == [1.5, 2.5, 3.0]) // 1.5, "2.5"->2.5, 3->3.0 + #expect(fixture.objectArray == ["string"]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$stringArray.results.count == 5) + #expect(fixture.$stringArray.errors.count == 1) // null + + #expect(fixture.$intArray.results.count == 5) + #expect(fixture.$intArray.errors.count == 3) // "invalid", 3.14, true + + #expect(fixture.$doubleArray.results.count == 5) + #expect(fixture.$doubleArray.errors.count == 2) // null, "invalid" + + #expect(fixture.$objectArray.results.count == 2) + #expect(fixture.$objectArray.errors.count == 1) // Objects cannot be converted to String + #endif + } + + @Test("error reporting with JSONDecoder") + func errorReporting() throws { + let json = """ + { + "stringArray": [1, null, "three"], + "intArray": ["not a number", 2], + "doubleArray": [1.5, "invalid", null], + "objectArray": [] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + // Check if errors were reported + let digest = try #require(errorDigest) + // null and conversion failure errors + #expect(digest.errors.count >= 3) + #if DEBUG + print("Error digest: \(digest.debugDescription)") + #endif + } + + @Test("complete failure results in empty array") + func completeFailure() async throws { + let json = """ + { + "stringArray": "not an array", + "intArray": 123, + "doubleArray": null, + "objectArray": true + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Non-array values cause decoding failure + confirmation() + } + } + } + + @Test("missing keys result in decoding error") + func missingKeys() async throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Decoding failure as required property + confirmation() + } + } + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift index 35fded4..356369c 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessArrayTests.swift @@ -56,4 +56,3 @@ final class LosslessArrayTests: XCTestCase { XCTAssertEqual(fixture.values, [1, 2, 3]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift new file mode 100644 index 0000000..305cd1c --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LosslessValue/LosslessValueResilientTests.swift @@ -0,0 +1,133 @@ +// +// LosslessValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LosslessValue Resilient Decoding") +struct LosslessValueResilientTests { + struct Fixture: Decodable { + @LosslessValue var stringValue: String + @LosslessValue var intValue: Int + @LosslessValue var boolValue: Bool + @LosslessValue var doubleValue: Double + } + + @Test("projected value provides error information") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringValue": 123, + "intValue": "456", + "boolValue": "true", + "doubleValue": "3.14" + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - all values converted + #expect(fixture.stringValue == "123") + #expect(fixture.intValue == 456) + #expect(fixture.boolValue == true) + #expect(fixture.doubleValue == 3.14) + + #if DEBUG + // Access success info through projected value + #expect(fixture.$stringValue.outcome == .decodedSuccessfully) + #expect(fixture.$intValue.outcome == .decodedSuccessfully) + #expect(fixture.$boolValue.outcome == .decodedSuccessfully) + #expect(fixture.$doubleValue.outcome == .decodedSuccessfully) + #endif + } + + @Test("null values handling") + func nullValues() async throws { + let json = """ + { + "stringValue": null, + "intValue": null, + "boolValue": null, + "doubleValue": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // null values cannot be handled by LosslessValue + confirmation() + } + } + } + + @Test("unconvertible values") + func unconvertibleValues() async throws { + let json = """ + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Complex types cannot be converted + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func errorReporting() async throws { + let json = """ + { + "stringValue": {"key": "value"}, + "intValue": [1, 2, 3], + "boolValue": {"nested": true}, + "doubleValue": ["array"] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Complex types cannot be converted + confirmation() + } + } + + let errorDigest = errorReporter.flushReportedErrors() + + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) // At least 1 error occurred + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift new file mode 100644 index 0000000..d972c4f --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayResilientTests.swift @@ -0,0 +1,148 @@ +// +// LossyArrayResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LossyArray Resilient Decoding") +struct LossyArrayResilientTests { + struct Fixture: Decodable { + struct NestedObject: Decodable, Equatable { + let id: Int + let name: String + } + + @LossyArray var integers: [Int] + @LossyArray var strings: [String] + @LossyArray var objects: [NestedObject] + } + + @Test("projected value provides error information in DEBUG") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "integers": [1, "invalid", 3, null, 5], + "strings": ["hello", 123, "world", null], + "objects": [ + {"id": 1, "name": "first"}, + {"id": "invalid", "name": "second"}, + {"id": 3, "name": "third"} + ] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior + #expect(fixture.integers == [1, 3, 5]) + #expect(fixture.strings == ["hello", "world"]) + #expect(fixture.objects == [ + Fixture.NestedObject(id: 1, name: "first"), + Fixture.NestedObject(id: 3, name: "third"), + ]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$integers.results.count == 5) + #expect(fixture.$integers.errors.count == 2) // "invalid" and null + + /// Check success/failure of each element + let intResults = fixture.$integers.results + #expect(try intResults[0].get() == 1) + #expect(intResults[1].isFailure == true) + #expect(try intResults[2].get() == 3) + #expect(intResults[3].isFailure == true) + #expect(try intResults[4].get() == 5) + + // strings validation + #expect(fixture.$strings.results.count == 4) + #expect(fixture.$strings.errors.count == 2) // 123 and null + + // objects validation + #expect(fixture.$objects.results.count == 3) + #expect(fixture.$objects.errors.count == 1) // "invalid" id + #endif + } + + @Test("error reporting with JSONDecoder") + func errorReporting() throws { + let json = """ + { + "integers": [1, "two", 3], + "strings": ["a", "b", "c"], + "objects": [] + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + } + + @Test("decode with reportResilientDecodingErrors") + func decodeWithReportFlag() throws { + let json = """ + { + "integers": [1, "invalid", 3], + "strings": [], + "objects": [] + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let (fixture, errorDigest) = try decoder.decode( + Fixture.self, + from: data, + reportResilientDecodingErrors: true + ) + + #expect(fixture.integers == [1, 3]) + + #expect(errorDigest != nil) + #expect(errorDigest?.errors.count ?? 0 >= 1) + } + + @Test("empty array on complete failure") + func emptyArrayOnCompleteFailure() throws { + let json = """ + { + "integers": "not an array", + "strings": 123, + "objects": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.integers == []) + #expect(fixture.strings == []) + #expect(fixture.objects == []) + + #if DEBUG + // Error info when entire array decoding fails + #expect(fixture.$integers.error != nil) + #expect(fixture.$strings.error != nil) + #expect(fixture.$objects.error == nil) // null is not an error + #endif + } +} diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift index 0230c20..0fab230 100644 --- a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyArrayTests.swift @@ -80,4 +80,3 @@ final class LossyArrayTests: XCTestCase { XCTAssertEqual(fixture2.theValues, [Date(timeIntervalSince1970: 123)]) } } - diff --git a/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift new file mode 100644 index 0000000..08ee5ca --- /dev/null +++ b/Tests/KarrotCodableKitTests/BetterCodable/LossyValue/LossyDictionaryResilientTests.swift @@ -0,0 +1,159 @@ +// +// LossyDictionaryResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LossyDictionary Resilient Decoding") +struct LossyDictionaryResilientTests { + struct Fixture: Decodable { + @LossyDictionary var stringDict: [String: Int] + @LossyDictionary var intDict: [Int: String] + + struct NestedObject: Decodable, Equatable { + let id: Int + let name: String + } + + @LossyDictionary var objectDict: [String: NestedObject] + } + + @Test("projected value provides error information for each failed key-value pair") + func projectedValueProvidesErrorInfo() throws { + let json = """ + { + "stringDict": { + "one": 1, + "two": "invalid", + "three": 3, + "four": null + }, + "intDict": { + "1": "first", + "2": "second", + "3": "third", + "invalid": "should be ignored" + }, + "objectDict": { + "obj1": {"id": 1, "name": "first"}, + "obj2": {"id": "invalid", "name": "second"}, + "obj3": {"id": 3, "name": "third"} + } + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // Verify default behavior - only valid key-value pairs included + #expect(fixture.stringDict == ["one": 1, "three": 3]) + #expect(fixture.intDict == [1: "first", 2: "second", 3: "third"]) // All Int keys are valid + #expect(fixture.objectDict == [ + "obj1": Fixture.NestedObject(id: 1, name: "first"), + "obj3": Fixture.NestedObject(id: 3, name: "third"), + ]) + + #if DEBUG + // Access error info through projected value + #expect(fixture.$stringDict.results.count == 4) + #expect(fixture.$stringDict.errors.count == 2) // "invalid" and null + + /// Check success/failure of each key + let stringResults = fixture.$stringDict.results + #expect(stringResults["one"]?.success == 1) + #expect(stringResults["two"]?.isFailure == true) + + // intDict validation - Int key type + #expect(fixture.$intDict.errors.count == 0) // All keys are valid + + // objectDict validation + #expect(fixture.$objectDict.results.count == 3) + #expect(fixture.$objectDict.errors.count == 1) // "invalid" id + #endif + } + + @Test("error reporting with JSONDecoder") + func errorReporting() throws { + let json = """ + { + "stringDict": { + "a": "not a number", + "b": 2, + "c": false + }, + "intDict": {}, + "objectDict": {} + } + """ + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + _ = try decoder.decode(Fixture.self, from: data) + + let errorDigest = errorReporter.flushReportedErrors() + + // Check if errors were reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 2) // Errors for keys "a" and "c" + } + + @Test("complete failure results in empty dictionary") + func completeFailure() throws { + let json = """ + { + "stringDict": "not a dictionary", + "intDict": 123, + "objectDict": null + } + """ + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.stringDict == [:]) + #expect(fixture.intDict == [:]) + #expect(fixture.objectDict == [:]) + + #if DEBUG + // Error info when entire dictionary decoding fails + #expect(fixture.$stringDict.error != nil) + #expect(fixture.$intDict.error != nil) + #expect(fixture.$objectDict.error == nil) // null is not an error + #endif + } + + @Test("missing keys result in empty dictionary") + func missingKeys() throws { + let json = "{}" + + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + let fixture = try decoder.decode(Fixture.self, from: data) + + #expect(fixture.stringDict == [:]) + #expect(fixture.intDict == [:]) + #expect(fixture.objectDict == [:]) + + #if DEBUG + #expect(fixture.$stringDict.outcome == .keyNotFound) + #expect(fixture.$intDict.outcome == .keyNotFound) + #expect(fixture.$objectDict.outcome == .keyNotFound) + + // No error when key is missing + #expect(fixture.$stringDict.error == nil) + #expect(fixture.$intDict.error == nil) + #expect(fixture.$objectDict.error == nil) + #endif + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..a3573d9 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueResilientTests.swift @@ -0,0 +1,204 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("DefaultEmptyPolymorphicArrayValue Resilient Decoding") +struct DefaultEmptyPolymorphicArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.DefaultEmptyPolymorphicArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Successful array decoding should have decodedSuccessfully outcome") + func successfulArrayDecoding() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "First", + "description": "First callout", + "icon": "icon1.png" + }, + { + "type": "actionable-callout", + "title": "Second", + "description": "Second callout", + "action": "https://example.com" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array when array contains any invalid element") + func arrayWithAnyInvalidElement() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #endif + } + + @Test("Should return empty array when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .keyNotFound) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array for null value") + func nullValue() throws { + // given + let json = """ + { + "notices": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .valueWasNil) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Should return empty array for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #endif + } + + @Test("Error reporter should be called") + func errorReporting() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift similarity index 95% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift index 2e25b89..10e91c1 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/DefaultEmptyPolymorphicArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/DefaultEmptyPolymorphicArrayValueTests.swift @@ -113,7 +113,7 @@ class DefaultEmptyPolymorphicArrayValueTests: XCTestCase { extension DefaultEmptyPolymorphicArrayValueTests { func testDecodingFailElementInDefaultEmptyPolymorphicArrayValue() throws { - // given: An array where one element (notice) is missing the required 'description' parameter. + // given let jsonData = #""" { "notices1" : [ @@ -130,10 +130,10 @@ extension DefaultEmptyPolymorphicArrayValueTests { } """# - // when: During decoding. + // when let result = try JSONDecoder().decode(OptionalArrayDummyResponse.self, from: Data(jsonData.utf8)) - // then: Returns an empty array. + // then XCTAssertTrue(result.notices1.isEmpty) } } @@ -160,7 +160,7 @@ extension DefaultEmptyPolymorphicArrayValueTests { from: Data(jsonData.utf8) ) - // thens + // then XCTAssertTrue(result.notices1.isEmpty) XCTAssertEqual(result.notices2.first?.type, .callout) XCTAssertTrue(result.notices3.isEmpty) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..d56c2e8 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicArrayValueResilientTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("PolymorphicArrayValue Resilient Decoding") +struct PolymorphicArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.PolymorphicArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Successful array decoding should have decodedSuccessfully outcome") + func successfulArrayDecoding() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "First", + "description": "First callout", + "icon": "icon1.png" + }, + { + "type": "actionable-callout", + "title": "Second", + "description": "Second callout", + "action": "https://example.com" + }, + { + "type": "dismissible-callout", + "title": "Third", + "description": "Third callout", + "key": "dismiss-key" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 3) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + #expect(result.notices[2] is DummyDismissibleCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #endif + } + + @Test("Array should fail to decode if any element fails") + func arrayWithInvalidElement() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when/then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when/then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when/then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Array element errors should be reported") + func arrayElementErrorReported() throws { + // given + let json = """ + { + "notices": [ + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // then + #expect(throws: Error.self) { + _ = try decoder.decode(Fixture.self, from: data) + } + + // PolymorphicValue reports errors, so error digest should exist + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift new file mode 100644 index 0000000..8de0e5f --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueResilientTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("PolymorphicLossyArrayValue Resilient Decoding") +struct PolymorphicLossyArrayValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.PolymorphicLossyArray var notices: [any DummyNotice] + } + + @Test("Empty array decoding should have decodedSuccessfully outcome") + func emptyArray() throws { + // given + let json = """ + { + "notices": [] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Successful array decoding should have all elements succeed") + func successfulArrayDecoding() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "First", + "description": "First callout", + "icon": "icon1.png" + }, + { + "type": "actionable-callout", + "title": "Second", + "description": "Second callout", + "action": "https://example.com" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyActionableCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.count == 2) + #expect(try result.$notices.results.allSatisfy(\.isSuccess)) + #endif + } + + @Test("Should decode only valid elements when array has invalid elements") + func arrayWithInvalidElements() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + }, + { + "type": "dismissible-callout", + "title": "Also Valid", + "description": "Another valid callout", + "key": "dismiss-key" + }, + "not-an-object", + null, + 123 + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 2) + #expect(result.notices[0] is DummyCallout) + #expect(result.notices[1] is DummyDismissibleCallout) + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices.results.count == 6) + + // Only first and third elements succeed + #expect(result.$notices.results[0].isSuccess) + #expect(result.$notices.results[1].isFailure) + #expect(result.$notices.results[2].isSuccess) + #expect(result.$notices.results[3].isFailure) + #expect(result.$notices.results[4].isFailure) + #expect(result.$notices.results[5].isFailure) + #endif + } + + @Test("Should return empty array when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + #expect(result.$notices.outcome == .keyNotFound) + #expect(result.$notices.error == nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Should return empty array for invalid type") + func invalidType() throws { + // given + let json = """ + { + "notices": "not an array" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notices.isEmpty) + #if DEBUG + if case .recoveredFrom = result.$notices.outcome { + // Expected + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notices.error != nil) + #expect(result.$notices.results.isEmpty) + #endif + } + + @Test("Error reporter should be called partially") + func partialErrorReporting() throws { + // given + let json = """ + { + "notices": [ + { + "type": "callout", + "title": "Valid", + "description": "Valid callout", + "icon": "icon.png" + }, + { + "type": "invalid-type" + } + ] + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notices.count == 1) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift similarity index 99% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift index 45e7c87..0b9d618 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicLossyArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/ArrayValue/PolymorphicLossyArrayValueTests.swift @@ -161,7 +161,7 @@ extension PolymorphicLossyArrayValueTests { from: Data(jsonData.utf8) ) - // thens + // then XCTAssertTrue(result.notices1.isEmpty) XCTAssertEqual(result.notices2.first?.type, .callout) XCTAssertTrue(result.notices3.isEmpty) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumCodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumCodableTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumCodableTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumCodableTests.swift diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumDecodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift similarity index 99% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumDecodableTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift index 73bf549..55da99f 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicEnumDecodableTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Enum/PolymorphicEnumDecodableTests.swift @@ -72,4 +72,3 @@ class PolymorphicEnumDecodableTests: XCTestCase { } } } - diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift new file mode 100644 index 0000000..c96d1e4 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueResilientTests.swift @@ -0,0 +1,311 @@ +// +// OptionalPolymorphicArrayValueResilientTests.swift +// KarrotCodableKit +// +// Created by Elon on 2025-07-28. +// + +import Foundation +import Testing + +import KarrotCodableKit + +// MARK: - Test Types + +@CustomCodable(codingKeyStyle: .snakeCase) +struct ResilientOptionalPolymorphicArrayDummyResponse { + @DummyNotice.OptionalPolymorphicArray + var notices: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices2: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices3: [any DummyNotice]? + + @DummyNotice.OptionalPolymorphicArray + var notices4: [any DummyNotice]? +} + +struct OptionalPolymorphicArrayValueResilientTests { + + // MARK: - Successful Decoding Tests + + @Test + func decodesValidArrayWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "icon": "test_icon1", + "type": "callout" + }, + { + "description": "test2", + "action": "https://example.com", + "type": "actionable-callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices = try #require(result.notices) + #expect(notices.count == 2) + + let firstNotice = try #require(notices[0] as? DummyCallout) + #expect(firstNotice.description == "test1") + #expect(firstNotice.icon == "test_icon1") + #expect(firstNotice.type == .callout) + + let secondNotice = try #require(notices[1] as? DummyActionableCallout) + #expect(secondNotice.description == "test2") + #expect(secondNotice.action == URL(string: "https://example.com")) + #expect(secondNotice.type == .actionableCallout) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func decodesEmptyArrayWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": [] + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices = try #require(result.notices) + #expect(notices.isEmpty) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func decodesNullValueWithoutErrors() throws { + // given + let jsonData = #""" + { + "notices": null + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices == nil) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + @Test + func decodesMissingKeyWithoutErrors() throws { + // given + let jsonData = #""" + { + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.notices == nil) + + #if DEBUG + #expect(result.$notices.error == nil) + #endif + } + + // MARK: - Error Reporting Tests + + @Test + func reportsErrorWhenInvalidElementInArray() throws { + // given - Missing required 'description' field in second element + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "type": "callout" + }, + { + "type": "callout" + } + ] + } + """# + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // when & then + #expect(throws: Error.self) { + _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + } + + // Verify errors were reported + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + } + + @Test + func reportsErrorWhenNotArrayType() throws { + // given + let jsonData = #""" + { + "notices": "not an array" + } + """# + + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + // when & then + #expect(throws: Error.self) { + _ = try decoder.decode(ResilientOptionalPolymorphicArrayDummyResponse.self, from: Data(jsonData.utf8)) + } + + // Verify errors were reported + let errorDigest = errorReporter.flushReportedErrors() + #expect(errorDigest != nil) + } + + // MARK: - Projected Value Tests + + #if DEBUG + @Test + func projectedValueReturnsNilErrorForSuccessfulDecoding() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test", + "type": "callout" + } + ] + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .decodedSuccessfully) + } + + @Test + func projectedValueReturnsOutcomeForNilValue() throws { + // given + let jsonData = #""" + { + "notices": null + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .valueWasNil) + } + + @Test + func projectedValueReturnsOutcomeForMissingKey() throws { + // given + let jsonData = #""" + { + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + #expect(result.$notices.error == nil) + #expect(result.$notices.outcome == .keyNotFound) + } + #endif + + // MARK: - Multiple Properties Test + + @Test + func decodesMultiplePropertiesCorrectly() throws { + // given + let jsonData = #""" + { + "notices": [ + { + "description": "test1", + "type": "callout" + } + ], + "notices2": null, + "notices3": [] + } + """# + + // when + let result = try JSONDecoder().decode( + ResilientOptionalPolymorphicArrayDummyResponse.self, + from: Data(jsonData.utf8) + ) + + // then + let notices = try #require(result.notices) + #expect(notices.count == 1) + #expect(result.notices2 == nil) + + let notices3 = try #require(result.notices3) + #expect(notices3.isEmpty) + + #expect(result.notices4 == nil) // missing key + + #if DEBUG + #expect(result.$notices.outcome == .decodedSuccessfully) + #expect(result.$notices2.outcome == .valueWasNil) + #expect(result.$notices3.outcome == .decodedSuccessfully) + #expect(result.$notices4.outcome == .keyNotFound) + #endif + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift index 86a9504..977daf6 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicArrayValueTests.swift @@ -13,7 +13,7 @@ import KarrotCodableKit struct OptionalPolymorphicArrayValueTests { @Test - func testDecodingOptionalPolymorphicArrayValue() throws { + func decodingOptionalPolymorphicArrayValue() throws { // given let jsonData = #""" { @@ -39,22 +39,22 @@ struct OptionalPolymorphicArrayValueTests { // then let notices1 = try #require(result.notices1) #expect(notices1.count == 2) - + let firstNotice = try #require(notices1[0] as? DummyCallout) #expect(firstNotice.description == "test1") #expect(firstNotice.icon == "test_icon1") #expect(firstNotice.type == .callout) - + let secondNotice = try #require(notices1[1] as? DummyActionableCallout) #expect(secondNotice.description == "test2") #expect(secondNotice.action == URL(string: "https://example.com")) #expect(secondNotice.type == .actionableCallout) - + #expect(result.notices2 == nil) } @Test - func testEncodingOptionalPolymorphicArrayValue() throws { + func encodingOptionalPolymorphicArrayValue() throws { // given let response = OptionalPolymorphicArrayDummyResponse( notices1: [ @@ -92,7 +92,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithEmptyArray() throws { + func decodingOptionalPolymorphicArrayValueWithEmptyArray() throws { // given let jsonData = #""" { @@ -111,7 +111,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithMissingKey() throws { + func decodingOptionalPolymorphicArrayValueWithMissingKey() throws { // given let jsonData = #""" { @@ -130,7 +130,7 @@ struct OptionalPolymorphicArrayValueTests { // then #expect(result.notices1 == nil) - + let notices2 = try #require(result.notices2) #expect(notices2.count == 1) let notice = try #require(notices2[0] as? DummyCallout) @@ -138,7 +138,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWithInvalidElement() throws { + func decodingOptionalPolymorphicArrayValueWithInvalidElement() throws { // given - Array with one invalid element (missing required 'description' property) let jsonData = #""" { @@ -163,7 +163,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testDecodingOptionalPolymorphicArrayValueWhenNotArray() throws { + func decodingOptionalPolymorphicArrayValueWhenNotArray() throws { // given - Value is not an array let jsonData = #""" { @@ -182,7 +182,7 @@ struct OptionalPolymorphicArrayValueTests { } @Test - func testEncodingDecodingNilValues() throws { + func encodingDecodingNilValues() throws { // given let response = OptionalPolymorphicArrayDummyResponse( notices1: nil, @@ -211,4 +211,4 @@ struct OptionalPolymorphicArrayValueTests { #expect(decodedResponse.notices1 == nil) #expect(decodedResponse.notices2 == nil) } -} \ No newline at end of file +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift new file mode 100644 index 0000000..dec3c58 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueResilientTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("LossyOptionalPolymorphicValue Resilient Decoding") +struct LossyOptionalPolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.LossyOptionalPolymorphic var notice: (any DummyNotice)? + } + + @Test("Outcome should be valueWasNil when decoding nil value") + func nilValue() throws { + // given + let json = """ + { + "notice": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .valueWasNil) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be keyNotFound when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .keyNotFound) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be decodedSuccessfully for successful decoding") + func successfulDecoding() throws { + // given + let json = """ + { + "notice": { + "__typename": "callout", + "type": "callout", + "title": "Test Title", + "description": "Hello", + "icon": "icon.png" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + let callout = try #require(result.notice as? DummyCallout) + #expect(callout.type == .callout) + #expect(callout.title == "Test Title") + #expect(callout.description == "Hello") + + #if DEBUG + #expect(result.$notice.outcome == .decodedSuccessfully) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Should return nil and record error for unknown type") + func unknownTypeReturnsNil() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + #if DEBUG + if case .recoveredFrom(let error, _) = result.$notice.outcome { + #expect(error is DecodingError) + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notice.error != nil) + #endif + } + + @Test("Should return nil and record error for invalid JSON format") + func invalidJSONReturnsNil() throws { + // given + let json = """ + { + "notice": "invalid" + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + #if DEBUG + if case .recoveredFrom(let error, _) = result.$notice.outcome { + #expect(error is DecodingError) + } else { + Issue.record("Expected recoveredFrom outcome") + } + #expect(result.$notice.error != nil) + #endif + } + + @Test("Error reporter should be called") + func errorReporterCalled() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let result = try decoder.decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + + let errorDigest = errorReporter.flushReportedErrors() + let digest = try #require(errorDigest) + let errors = digest.errors + #expect(errors.count >= 1) + #expect(errors.first is DecodingError) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift similarity index 99% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift index 283ec51..6652d12 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/LossyOptionalPolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/LossyOptionalPolymorphicValueTests.swift @@ -117,7 +117,7 @@ extension LossyOptionalPolymorphicValueTests { from: Data(jsonData.utf8) ) - // thens + // then XCTAssertNil(result.notice1) XCTAssertEqual(result.notice2?.type, .callout) XCTAssertNil(result.notice3) diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift new file mode 100644 index 0000000..032a0a8 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueResilientTests.swift @@ -0,0 +1,117 @@ +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("OptionalPolymorphicValue Resilient Decoding") +struct OptionalPolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.OptionalPolymorphic var notice: (any DummyNotice)? + } + + @Test("Outcome should be valueWasNil when decoding nil value") + func nilValue() throws { + // given + let json = """ + { + "notice": null + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .valueWasNil) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be keyNotFound when key is missing") + func missingKey() throws { + // given + let json = """ + {} + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + #expect(result.notice == nil) + #if DEBUG + #expect(result.$notice.outcome == .keyNotFound) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Outcome should be decodedSuccessfully for successful decoding") + func successfulDecoding() throws { + // given + let json = """ + { + "notice": { + "__typename": "callout", + "type": "callout", + "title": "Test Title", + "description": "Hello", + "icon": "icon.png" + } + } + """ + + // when + let data = try #require(json.data(using: .utf8)) + let result = try JSONDecoder().decode(Fixture.self, from: data) + + // then + let callout = try #require(result.notice as? DummyCallout) + #expect(callout.type == .callout) + #expect(callout.title == "Test Title") + #expect(callout.description == "Hello") + + #if DEBUG + #expect(result.$notice.outcome == .decodedSuccessfully) + #expect(result.$notice.error == nil) + #endif + } + + @Test("Should throw error for unknown type") + func unknownTypeThrowsError() throws { + // given + let json = """ + { + "notice": { + "__typename": "unknown", + "id": "1" + } + } + """ + + // when/then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + + @Test("Should throw error for invalid JSON format") + func invalidJSONThrowsError() throws { + // given + let json = """ + { + "notice": "invalid" + } + """ + + // when/then + let data = try #require(json.data(using: .utf8)) + #expect(throws: Error.self) { + _ = try JSONDecoder().decode(Fixture.self, from: data) + } + } + +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift similarity index 100% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalPolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/OptionalValue/OptionalPolymorphicValueTests.swift diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift index baf2611..85ea3b9 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/TestDoubles/UnnestedPolymorphicCodableDummy.swift @@ -55,7 +55,6 @@ struct OptionalViewItem: ViewItem { let url: URL? } - // MARK: - Edge Case Test Structs @UnnestedPolymorphicCodable( diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift index 1cf0f11..d3edb10 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/UnnestedPolymorphicTests/UnnestedPolymorphicDecodableTests.swift @@ -56,7 +56,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() let result = try decoder.decode(OptionalDecodableViewItem.self, from: data) @@ -82,7 +82,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() let result = try decoder.decode(OptionalDecodableViewItem.self, from: data) @@ -106,7 +106,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then @@ -125,7 +125,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then @@ -145,7 +145,7 @@ struct UnnestedPolymorphicDecodableTests { """ // when - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) let decoder = JSONDecoder() // then diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift new file mode 100644 index 0000000..0049846 --- /dev/null +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueResilientTests.swift @@ -0,0 +1,140 @@ +// +// PolymorphicValueResilientTests.swift +// KarrotCodableKitTests +// +// Created by Elon on 4/9/25. +// + +import Foundation +import Testing +@testable import KarrotCodableKit + +@Suite("PolymorphicValue Resilient Decoding") +struct PolymorphicValueResilientTests { + struct Fixture: Decodable { + @DummyNotice.Polymorphic var notice: any DummyNotice + } + + @Test("projected value provides error information") + func projectedValueProvidesErrorInfo() throws { + // given + let json = """ + { + "notice": { + "type": "callout", + "description": "test description", + "icon": "test_icon" + } + } + """ + + // when + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + let fixture = try decoder.decode(Fixture.self, from: data) + + // then + // Verify basic behavior + #expect(fixture.notice.description == "test description") + #expect((fixture.notice as? DummyCallout)?.icon == "test_icon") + + #if DEBUG + // Access success info via projected value + #expect(fixture.$notice.outcome == .decodedSuccessfully) + #endif + } + + @Test("unknown type handling with fallback") + func unknownType() throws { + // given + let json = """ + { + "notice": { + "type": "unknown-type", + "description": "test description" + } + } + """ + + // when + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + // DummyNotice has fallback type configured so should succeed + let fixture = try decoder.decode(Fixture.self, from: data) + + // then + // Verify decoded as fallback type + #expect(fixture.notice is DummyUndefinedCallout) + #expect(fixture.notice.description == "test description") + + #if DEBUG + // Access success info via projected value + #expect(fixture.$notice.outcome == .decodedSuccessfully) + #endif + } + + @Test("null values handling") + func nullValues() async throws { + // given + let json = """ + { + "notice": null + } + """ + + // when/then + let decoder = JSONDecoder() + let data = try #require(json.data(using: .utf8)) + + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Verify specific error type if possible + // e.g., check for DecodingError.valueNotFound or similar + confirmation() + } + } + } + + @Test("error reporting with JSONDecoder") + func errorReporting() async throws { + // given + let json = """ + { + "notice": { + "type": "dismissible-callout", + "description": "test", + "title": "title", + "key": 123 + } + } + """ + + // when + let decoder = JSONDecoder() + let errorReporter = decoder.enableResilientDecodingErrorReporting() + + let data = try #require(json.data(using: .utf8)) + + // then + await confirmation(expectedCount: 1) { confirmation in + do { + _ = try decoder.decode(Fixture.self, from: data) + Issue.record("Should have thrown") + } catch { + // Decoding failed due to type mismatch (key should be String) + confirmation() + } + } + + // then + let errorDigest = errorReporter.flushReportedErrors() + + // Check if error was reported + let digest = try #require(errorDigest) + #expect(digest.errors.count >= 1) + } +} diff --git a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicValueTests.swift b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift similarity index 99% rename from Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicValueTests.swift rename to Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift index b12838f..09f4801 100644 --- a/Tests/KarrotCodableKitTests/PolymorphicCodable/PolymorphicValueTests.swift +++ b/Tests/KarrotCodableKitTests/PolymorphicCodable/Value/PolymorphicValueTests.swift @@ -176,4 +176,3 @@ extension PolymorphicValueTests { XCTAssertEqual(result.notices[2].type, .undefinedCallout) } } - diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift index 1cc6882..0061c20 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomCodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomCodableMacroTests: XCTestCase { } } - // MARK: - Nested Codable extension CustomCodableMacroTests { @@ -188,7 +187,6 @@ extension CustomCodableMacroTests { } } - // MARK: - CodableKey extension CustomCodableMacroTests { @@ -275,7 +273,6 @@ extension CustomCodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomCodableMacroTests { @@ -305,7 +302,7 @@ extension CustomCodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift index 1e28eb8..f804cb0 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomDecodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomDecodableMacroTests: XCTestCase { } } - // MARK: - Nested Decodable extension CustomDecodableMacroTests { @@ -188,7 +187,6 @@ extension CustomDecodableMacroTests { } } - // MARK: - CodableKey extension CustomDecodableMacroTests { @@ -275,7 +273,6 @@ extension CustomDecodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomDecodableMacroTests { @@ -305,7 +302,7 @@ extension CustomDecodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift index 8642829..ac44004 100644 --- a/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/CustomCodableMacros/CustomEncodableMacroTests.swift @@ -89,7 +89,6 @@ final class CustomEncodableMacroTests: XCTestCase { } } - // MARK: - Nested Encodable extension CustomEncodableMacroTests { @@ -188,7 +187,6 @@ extension CustomEncodableMacroTests { } } - // MARK: - CodableKey extension CustomEncodableMacroTests { @@ -275,7 +273,6 @@ extension CustomEncodableMacroTests { } } - // MARK: - cannotApplyToEnum extension CustomEncodableMacroTests { @@ -305,7 +302,7 @@ extension CustomEncodableMacroTests { message: "`@CustomCodable`, `@CustomEncodable`, `@CustomDecodable` cannot be applied to enum", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift index 90491b9..d43c24e 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableMacroTests.swift @@ -101,7 +101,7 @@ final class PolymorphicCodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift index acf743a..0b611ce 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicCodableStrategyProvidingMacroTests.swift @@ -132,7 +132,7 @@ final class PolymorphicCodableStrategyProvidingMacroTests: XCTestCase { ) } } - + extension Notice { public typealias Polymorphic = PolymorphicValue public typealias OptionalPolymorphic = OptionalPolymorphicValue diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift index 5b03e1e..24952e0 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicDeodableMacroTests.swift @@ -104,7 +104,7 @@ final class PolymorphicDecodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -114,4 +114,3 @@ final class PolymorphicDecodableMacroTests: XCTestCase { #endif } } - diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift index 7fc49eb..a69bc98 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicCodableMacrosTests/PolymorphicEncodableMacroTests.swift @@ -104,7 +104,7 @@ final class PolymorphicEncodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift index 2657221..60e9104 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumCodableMacroTests.swift @@ -165,7 +165,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "`@PolymorphicEnumCodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -198,7 +198,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -231,7 +231,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -264,7 +264,7 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -275,7 +275,6 @@ final class PolymorphicEnumCodableMacroTests: XCTestCase { } } - // MARK: - fallbackCaseName extension PolymorphicEnumCodableMacroTests { @@ -368,7 +367,7 @@ extension PolymorphicEnumCodableMacroTests { message: "Missing fallback case: should be defined as `case undefinedCallout", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -401,7 +400,7 @@ extension PolymorphicEnumCodableMacroTests { message: "Invalid fallback case name: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift index 7cfb430..0bb9f5d 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumDecodableMacroTests.swift @@ -143,7 +143,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "`@PolymorphicEnumDecodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -176,7 +176,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -209,7 +209,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -242,7 +242,7 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -253,7 +253,6 @@ final class PolymorphicEnumDecodableMacroTests: XCTestCase { } } - // MARK: - fallbackCaseName extension PolymorphicEnumDecodableMacroTests { @@ -333,7 +332,7 @@ extension PolymorphicEnumDecodableMacroTests { message: "Missing fallback case: should be defined as `case undefinedCallout", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -366,7 +365,7 @@ extension PolymorphicEnumDecodableMacroTests { message: "Invalid fallback case name: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift index bb8b505..e78a7c7 100644 --- a/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift +++ b/Tests/KarrotCodableMacrosTests/PolymorphicEnumCodableMacroTests/PolymorphicEnumEncodableMacroTests.swift @@ -125,7 +125,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "`@PolymorphicEnumEncodable` can only be attached to enums", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -158,7 +158,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Invalid polymorphic identifier: expected a non-empty string.", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -191,7 +191,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Polymorphic Enum cases can only have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) @@ -224,7 +224,7 @@ final class PolymorphicEnumEncodableMacroTests: XCTestCase { message: "Polymorphic Enum cases should have one associated value", line: 1, column: 1 - ) + ), ], macros: testMacros, indentationWidth: .spaces(2) diff --git a/ThirdPartyLicenses/ResilientDecoding/LICENSE b/ThirdPartyLicenses/ResilientDecoding/LICENSE new file mode 100644 index 0000000..649eb52 --- /dev/null +++ b/ThirdPartyLicenses/ResilientDecoding/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Airbnb + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file