From d3171ed58ab7a658dcf983bb101d875d2e3fab55 Mon Sep 17 00:00:00 2001 From: Lukas Schmidt Date: Sun, 31 Mar 2024 14:39:57 +0200 Subject: [PATCH] Adds support to encode & decode Foundation.URL --- .../AutomergeKeyedDecodingContainer.swift | 10 ++++ .../AutomergeKeyedEncodingContainer.swift | 7 +++ .../Automerge/ScalarValueRepresentable.swift | 57 +++++++++++++++++++ .../CodableTests/AutomergeDecoderTests.swift | 3 + .../CodableTests/AutomergeEncoderTests.swift | 15 ++++- 5 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Sources/Automerge/Codable/Decoding/AutomergeKeyedDecodingContainer.swift b/Sources/Automerge/Codable/Decoding/AutomergeKeyedDecodingContainer.swift index e56f979d..ae495c6b 100644 --- a/Sources/Automerge/Codable/Decoding/AutomergeKeyedDecodingContainer.swift +++ b/Sources/Automerge/Codable/Decoding/AutomergeKeyedDecodingContainer.swift @@ -168,6 +168,16 @@ struct AutomergeKeyedDecodingContainer: KeyedDecodingContainerProt debugDescription: "Expected to decode \(T.self) from \(retrievedValue), but it wasn't a `.text` object." )) } + case is URL.Type: + let retrievedValue = try getValue(forKey: key) + guard case let .Scalar(.String(urlString)) = retrievedValue, let url = URL(string: urlString) else { + throw DecodingError.typeMismatch(T.self, .init( + codingPath: codingPath, + debugDescription: "Expected to decode \(URL.self) from \(retrievedValue), but it wasn't a `URL` type." + )) + } + + return url as! T default: let decoder = try decoderForKey(key) return try T(from: decoder) diff --git a/Sources/Automerge/Codable/Encoding/AutomergeKeyedEncodingContainer.swift b/Sources/Automerge/Codable/Encoding/AutomergeKeyedEncodingContainer.swift index 7cb9c762..ba7079ba 100644 --- a/Sources/Automerge/Codable/Encoding/AutomergeKeyedEncodingContainer.swift +++ b/Sources/Automerge/Codable/Encoding/AutomergeKeyedEncodingContainer.swift @@ -372,6 +372,13 @@ struct AutomergeKeyedEncodingContainer: KeyedEncodingContainerProt } } impl.mapKeysWritten.append(key.stringValue) + case is URL.Type: + let downcastData = value as! URL + if impl.cautiousWrite { + try checkTypeMatch(value: value, objectId: objectId, key: key, type: .uint) + } + try document.put(obj: objectId, key: key.stringValue, value: downcastData.toScalarValue()) + impl.mapKeysWritten.append(key.stringValue) default: let newEncoder = AutomergeEncoderImpl( userInfo: impl.userInfo, diff --git a/Sources/Automerge/ScalarValueRepresentable.swift b/Sources/Automerge/ScalarValueRepresentable.swift index 98bea2fd..e43eea84 100644 --- a/Sources/Automerge/ScalarValueRepresentable.swift +++ b/Sources/Automerge/ScalarValueRepresentable.swift @@ -91,6 +91,63 @@ extension Bool: ScalarValueRepresentable { } } +// MARK: URL Conversions + +/// A failure to convert an Automerge scalar value to or from a Boolean representation. +public enum URLScalarConversionError: LocalizedError { + case notStringValue(_ val: Value) + case notStringScalarValue(_ val: ScalarValue) + case notMatchingURLScheme(String) + + /// A localized message describing what error occurred. + public var errorDescription: String? { + switch self { + case .notStringScalarValue(let scalarValue): + return "Failed to read the scalar value \(scalarValue) as a String before converting to URL." + case .notStringValue(let value): + return "Failed to read the value \(value) as a String before converting to URL." + case .notMatchingURLScheme(let string): + return "Failed to convert the string \(string) to URL." + } + } + + /// A localized message describing the reason for the failure. + public var failureReason: String? { nil } +} + +extension URL: ScalarValueRepresentable { + + public static func fromValue(_ value: Value) -> Result { + if case .Scalar(.String(let urlString)) = value { + if let url = URL(string: urlString) { + return .success(url) + } else { + return .failure(.notMatchingURLScheme(urlString)) + } + } else { + return .failure(.notStringValue(value)) + } + } + + public static func fromScalarValue(_ val: ScalarValue) -> Result { + switch val { + case let .String(urlString): + if let url = URL(string: urlString) { + return .success(url) + } else { + return .failure(.notMatchingURLScheme(urlString)) + } + default: + return .failure(.notStringScalarValue(val)) + } + } + + public func toScalarValue() -> ScalarValue { + .String(self.absoluteString) + } +} + + // MARK: String Conversions /// A failure to convert an Automerge scalar value to or from a String representation. diff --git a/Tests/AutomergeTests/CodableTests/AutomergeDecoderTests.swift b/Tests/AutomergeTests/CodableTests/AutomergeDecoderTests.swift index a67a3d32..6c12d2f1 100644 --- a/Tests/AutomergeTests/CodableTests/AutomergeDecoderTests.swift +++ b/Tests/AutomergeTests/CodableTests/AutomergeDecoderTests.swift @@ -14,6 +14,7 @@ final class AutomergeDecoderTests: XCTestCase { try! doc.put(obj: ObjId.ROOT, key: "flag", value: .Boolean(true)) try! doc.put(obj: ObjId.ROOT, key: "count", value: .Int(5)) try! doc.put(obj: ObjId.ROOT, key: "uuid", value: .String("99CEBB16-1062-4F21-8837-CF18EC09DCD7")) + try! doc.put(obj: ObjId.ROOT, key: "url", value: .String("http://url.com")) try! doc.put(obj: ObjId.ROOT, key: "date", value: .Timestamp(-905182980)) try! doc.put(obj: ObjId.ROOT, key: "data", value: .Bytes(Data("hello".utf8))) @@ -47,6 +48,7 @@ final class AutomergeDecoderTests: XCTestCase { let date: Date let data: Data let uuid: UUID + let url: URL let notes: AutomergeText } let decoder = AutomergeDecoder(doc: doc) @@ -59,6 +61,7 @@ final class AutomergeDecoderTests: XCTestCase { XCTAssertEqual(decodedStruct.duration, 3.14159, accuracy: 0.0001) XCTAssertTrue(decodedStruct.flag) XCTAssertEqual(decodedStruct.count, 5) + XCTAssertEqual(decodedStruct.url, URL(string: "http://url.com")) let expectedUUID = UUID(uuidString: "99CEBB16-1062-4F21-8837-CF18EC09DCD7")! XCTAssertEqual(decodedStruct.uuid, expectedUUID) diff --git a/Tests/AutomergeTests/CodableTests/AutomergeEncoderTests.swift b/Tests/AutomergeTests/CodableTests/AutomergeEncoderTests.swift index 5f8cee6f..fde5c2da 100644 --- a/Tests/AutomergeTests/CodableTests/AutomergeEncoderTests.swift +++ b/Tests/AutomergeTests/CodableTests/AutomergeEncoderTests.swift @@ -28,6 +28,7 @@ final class AutomergeEncoderTests: XCTestCase { let date: Date let data: Data let uuid: UUID + let url: URL let notes: AutomergeText } let automergeEncoder = AutomergeEncoder(doc: doc) @@ -43,6 +44,7 @@ final class AutomergeEncoderTests: XCTestCase { date: earlyDate, data: Data("hello".utf8), uuid: UUID(uuidString: "99CEBB16-1062-4F21-8837-CF18EC09DCD7")!, + url: URL(string: "http://url.com")!, notes: AutomergeText("Something wicked this way comes.") ) @@ -98,6 +100,8 @@ final class AutomergeEncoderTests: XCTestCase { } else { try XCTFail("Didn't find an object at \(String(describing: doc.get(obj: ObjId.ROOT, key: "notes")))") } + + XCTAssertEqual(try doc.get(obj: ObjId.ROOT, key: "url"), .Scalar(.String("http://url.com"))) try debugPrint(doc.get(obj: ObjId.ROOT, key: "notes") as Any) } @@ -107,6 +111,7 @@ final class AutomergeEncoderTests: XCTestCase { let duration: Double let flag: Bool let count: Int + let url: URL } struct RootModel: Codable { @@ -115,7 +120,14 @@ final class AutomergeEncoderTests: XCTestCase { let automergeEncoder = AutomergeEncoder(doc: doc) - let sample = RootModel(example: SimpleStruct(name: "henry", duration: 3.14159, flag: true, count: 5)) + let sample = RootModel( + example: SimpleStruct( + name: "henry", + duration: 3.14159, + flag: true, + count: 5, + url: URL(string: "http://url.com")!) + ) try automergeEncoder.encode(sample) @@ -145,6 +157,7 @@ final class AutomergeEncoderTests: XCTestCase { } else { try XCTFail("Didn't find: \(String(describing: doc.get(obj: container_id, key: "count")))") } + XCTAssertEqual(try doc.get(obj: container_id, key: "url"), .Scalar(.String("http://url.com"))) } else { try XCTFail("Didn't find: \(String(describing: doc.get(obj: ObjId.ROOT, key: "example")))") }