diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7812ce3..11c836b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,10 +13,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: 🔍 Xcode Select - run: | - XCODE_PATH=`mdfind "kMDItemCFBundleIdentifier == 'com.apple.dt.Xcode' && kMDItemVersion == '16.*'" -onlyin /Applications | head -1` - echo "DEVELOPER_DIR=$XCODE_PATH/Contents/Developer" >> $GITHUB_ENV - name: Version run: swift --version - name: Build diff --git a/Sources/KeyValueDecoder.swift b/Sources/KeyValueDecoder.swift index bf49910..5d0794d 100644 --- a/Sources/KeyValueDecoder.swift +++ b/Sources/KeyValueDecoder.swift @@ -103,14 +103,36 @@ public struct KeyValueDecoder: Sendable { /// Decodes dates by casting from Any. case date - /// Decodes dates from ISO8601 strings. - case iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) - /// Decodes dates in terms of milliseconds since midnight UTC on January 1st, 1970. case millisecondsSince1970 /// Decodes dates in terms of seconds since midnight UTC on January 1st, 1970. case secondsSince1970 + + /// Decodes dates from Any using a closure + case custom(@Sendable (Any) throws -> Date) + + /// Decodes dates from ISO8601 strings. + static func iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) -> Self { + .custom { + guard let string = $0 as? String else { + throw Error("Expected String but found \(type(of: $0))") + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = options + guard let date = formatter.date(from: string) else { + throw Error("Failed to decode Date from ISO8601 string \(string)") + } + return date + } + } + } + + struct Error: LocalizedError { + var errorDescription: String? + init(_ message: String) { + self.errorDescription = message + } } } @@ -359,19 +381,18 @@ private extension KeyValueDecoder { switch strategy.dates { case .date: return try getValue() - case .iso8601(options: let options): - let string = try decode(String.self) - let formatter = ISO8601DateFormatter() - formatter.formatOptions = options - guard let date = formatter.date(from: string) else { - throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: codingPath, debugDescription: "Failed to decode Date from ISO8601 string \(string)")) - } - return date case .millisecondsSince1970: return try Date(timeIntervalSince1970: TimeInterval(decode(Int.self)) / 1000) case .secondsSince1970: return try Date(timeIntervalSince1970: TimeInterval(decode(Int.self))) + + case .custom(let transform): + do { + return try transform(self.value) + } catch { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: codingPath, debugDescription: error.localizedDescription)) + } } } diff --git a/Sources/KeyValueEncoder.swift b/Sources/KeyValueEncoder.swift index b51aeb5..36fe61e 100644 --- a/Sources/KeyValueEncoder.swift +++ b/Sources/KeyValueEncoder.swift @@ -77,14 +77,23 @@ public struct KeyValueEncoder: Sendable { /// Encodes dates by directly casting to Any. case date - /// Encodes dates from ISO8601 strings. - case iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) - /// Encodes dates to Int in terms of milliseconds since midnight UTC on January 1, 1970. case millisecondsSince1970 /// Encodes dates to Int in terms of seconds since midnight UTC on January 1, 1970. case secondsSince1970 + + /// Encodes dates to Any using a closure + case custom(@Sendable (Date) throws -> Any) + + /// Encodes dates to ISO8601 strings. + static func iso8601(options: ISO8601DateFormatter.Options = [.withInternetDateTime]) -> Self { + .custom { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = options + return formatter.string(from: $0) + } + } } } @@ -238,7 +247,7 @@ private extension KeyValueEncoder { } func encodeToValue(_ value: T) throws -> EncodedValue where T: Encodable { - guard let encoded = EncodedValue.makeValue(for: value, using: strategy) else { + guard let encoded = try EncodedValue.makeValue(for: value, at: codingPath, using: strategy) else { try value.encode(to: self) return try getEncodedValue() } @@ -338,7 +347,7 @@ private extension KeyValueEncoder { } func encode(_ value: T, forKey key: Key) throws { - if let val = EncodedValue.makeValue(for: value, using: strategy) { + if let val = try EncodedValue.makeValue(for: value, at: codingPath, using: strategy) { setValue(val, forKey: key) return } @@ -468,7 +477,7 @@ private extension KeyValueEncoder { } func encode(_ value: T) throws { - if let val = EncodedValue.makeValue(for: value, using: strategy) { + if let val = try EncodedValue.makeValue(for: value, at: codingPath.appending(index: count), using: strategy) { appendValue(val) return } @@ -587,7 +596,7 @@ private extension KeyValueEncoder { } func encode(_ value: T) throws where T: Encodable { - if let encoded = EncodedValue.makeValue(for: value, using: strategy) { + if let encoded = try EncodedValue.makeValue(for: value, at: codingPath, using: strategy) { self.value = encoded return } @@ -708,11 +717,25 @@ struct AnyCodingKey: CodingKey { extension KeyValueEncoder.EncodedValue { - static func makeValue(for value: Any, using strategy: KeyValueEncoder.EncodingStrategy) -> Self? { + static func makeValue(for value: Any, at codingPath: [any CodingKey], using strategy: KeyValueEncoder.EncodingStrategy) throws -> Self? { + do { + return try makeValue(for: value, using: strategy) + } catch { + let valueDescription = strategy.optionals.isNull(value) ? "nil" : String(describing: type(of: value)) + let context = EncodingError.Context( + codingPath: codingPath, + debugDescription: "\(valueDescription) at \(codingPath.makeKeyPath()) cannot be encoded. \(error.localizedDescription)", + underlyingError: error + ) + throw EncodingError.invalidValue(value, context) + } + } + + static func makeValue(for value: Any, using strategy: KeyValueEncoder.EncodingStrategy) throws -> Self? { if let dataValue = value as? Data { return .value(dataValue) } else if let dateValue = value as? Date { - return makeValue(for: dateValue, using: strategy.dates) + return try makeValue(for: dateValue, using: strategy.dates) } else if let urlValue = value as? URL { return .value(urlValue) } else if let decimalValue = value as? Decimal { @@ -722,14 +745,12 @@ extension KeyValueEncoder.EncodedValue { } } - static func makeValue(for date: Date, using strategy: KeyValueEncoder.DateEncodingStrategy) -> Self? { + static func makeValue(for date: Date, using strategy: KeyValueEncoder.DateEncodingStrategy) throws -> Self? { switch strategy { case .date: return .value(date) - case .iso8601(options: let options): - let f = ISO8601DateFormatter() - f.formatOptions = options - return .value(f.string(from: date)) + case .custom(let transform): + return try .value(transform(date)) case .millisecondsSince1970: return .value(Int(date.timeIntervalSince1970 * 1000)) case .secondsSince1970: diff --git a/Tests/KeyValueEncoderTests.swift b/Tests/KeyValueEncoderTests.swift index 87d7e94..77704ff 100644 --- a/Tests/KeyValueEncoderTests.swift +++ b/Tests/KeyValueEncoderTests.swift @@ -695,6 +695,19 @@ struct KeyValueEncodedTests { #expect( try encoder.encode(referenceDate) as? Int == 978307200 ) + +#if compiler(>=6.1) + encoder.dateEncodingStrategy = .custom { _ in throw KeyValueDecoder.Error("🐟") } + var error = #expect(throws: EncodingError.self) { + try encoder.encode(referenceDate) + } + #expect(error?.context?.debugDescription == "Date at SELF cannot be encoded. 🐟") + + error = #expect(throws: EncodingError.self) { + try encoder.encode(["calendar": [Date?.none, referenceDate]]) + } + #expect(error?.context?.debugDescription == "Optional at SELF.calendar[1] cannot be encoded. 🐟") +#endif } @Test @@ -708,7 +721,7 @@ struct KeyValueEncodedTests { } @Test - func aa() { + func isOptionalNone() { #expect(KeyValueEncoder.NilEncodingStrategy.isOptionalNone(Int?.none as Any)) #expect(KeyValueEncoder.NilEncodingStrategy.isOptionalNone(Int??.none as Any)) } @@ -831,7 +844,11 @@ extension KeyValueEncoder.EncodedValue { private extension KeyValueEncoder.EncodedValue { static func isSupportedValue(_ value: Any) -> Bool { - Self.makeValue(for: value, using: .default) != nil + do { + return try Self.makeValue(for: value, using: .default) != nil + } catch { + return false + } } } @@ -869,4 +886,16 @@ private struct Null: Encodable { try container.encodeNil() } } + +private extension EncodingError { + + var context: Context? { + switch self { + case .invalidValue(_, let context): + return context + default: + return nil + } + } +} #endif diff --git a/Tests/KeyValueEncoderXCTests.swift b/Tests/KeyValueEncoderXCTests.swift index aabb37b..1bf6056 100644 --- a/Tests/KeyValueEncoderXCTests.swift +++ b/Tests/KeyValueEncoderXCTests.swift @@ -769,7 +769,11 @@ extension KeyValueEncoder.EncodedValue { private extension KeyValueEncoder.EncodedValue { static func isSupportedValue(_ value: Any) -> Bool { - Self.makeValue(for: value, using: .default) != nil + do { + return try Self.makeValue(for: value, using: .default) != nil + } catch { + return false + } } }