├── .github ├── FUNDING.yml └── workflows │ └── swift-package.yml ├── .gitignore ├── .spi.yml ├── LICENSE ├── Package.swift ├── README.MD ├── Sources └── CloudKitCodable │ ├── CloudKitAssetValue.swift │ ├── CloudKitEnum.swift │ ├── CloudKitRecordDecoder.swift │ ├── CloudKitRecordEncoder.swift │ ├── CustomCloudKitEncodable.swift │ └── Documentation.docc │ ├── CloudKitCodable.md │ ├── DataTypes.md │ └── Example.md └── Tests └── CloudKitCodableTests ├── CloudKitRecordDecoderTests.swift ├── CloudKitRecordEncoderTests.swift ├── Fixtures └── Rambo.ckrecord ├── TestTypes ├── Person.swift ├── TestModelCustomAsset.swift ├── TestModelWithEnum.swift └── TestNestedModel.swift └── TestUtils.swift /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: insidegui 2 | -------------------------------------------------------------------------------- /.github/workflows/swift-package.yml: -------------------------------------------------------------------------------- 1 | name: Swift Package 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | __MACOSX 3 | *.pbxuser 4 | !default.pbxuser 5 | *.mode1v3 6 | !default.mode1v3 7 | *.mode2v3 8 | !default.mode2v3 9 | *.perspectivev3 10 | !default.perspectivev3 11 | *.xcworkspace 12 | !default.xcworkspace 13 | xcuserdata 14 | profile 15 | *.moved-aside 16 | DerivedData 17 | .idea/ 18 | Crashlytics.sh 19 | generatechangelog.sh 20 | Pods/ 21 | Carthage 22 | Provisioning 23 | Crashlytics.sh 24 | .swiftpm 25 | .build 26 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: [CloudKitCodable] 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright© 2018 Guilherme Rambo 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.5 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "CloudKitCodable", 6 | platforms: [ 7 | .macOS(.v11), 8 | .iOS(.v14), 9 | .tvOS(.v14), 10 | .watchOS(.v7) 11 | ], 12 | products: [ 13 | .library(name: "CloudKitCodable", targets: ["CloudKitCodable"]) 14 | ], 15 | targets: [ 16 | .target(name: "CloudKitCodable"), 17 | .testTarget( 18 | name: "CloudKitCodableTests", 19 | dependencies: ["CloudKitCodable"], 20 | resources: [ 21 | .copy("Fixtures/Rambo.ckrecord") 22 | ] 23 | ) 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | # CloudKit + Codable = ❤️ 2 | 3 | [![Badge showing the current build status](https://github.com/insidegui/CloudKitCodable/actions/workflows/swift-package.yml/badge.svg)](https://github.com/insidegui/CloudKitCodable/actions/workflows/swift-package.yml) 4 | 5 | This project implements `CloudKitRecordEncoder` and `CloudKitRecordDecoder`, allowing for custom data types to be converted to/from `CKRecord` automatically. 6 | 7 | ## Usage 8 | 9 | For details on how to use CloudKitCodable, please check the included documentation. 10 | 11 | ### Example 12 | 13 | Declaring a model that can be encoded as a `CKRecord`: 14 | 15 | ```swift 16 | struct Person: CustomCloudKitCodable { 17 | var cloudKitSystemFields: Data? 18 | let name: String 19 | let age: Int 20 | let website: URL 21 | let avatar: URL 22 | let isDeveloper: Bool 23 | } 24 | ``` 25 | 26 | Creating a `CKRecord` from a `CustomCloudKitCodable` type: 27 | 28 | ```swift 29 | let rambo = Person(...) 30 | 31 | do { 32 | let record = try CloudKitRecordEncoder().encode(rambo) 33 | // record is now a CKRecord you can upload to CloudKit 34 | } catch { 35 | // something went wrong 36 | } 37 | ``` 38 | 39 | Decoding a `CustomCloudKitCodable` type from a `CKRecord`: 40 | 41 | ```swift 42 | let record = // record obtained from CloudKit 43 | do { 44 | let person = try CloudKitRecordDecoder().decode(Person.self, from: record) 45 | } catch { 46 | // something went wrong 47 | } 48 | ``` 49 | 50 | ## Minimum Deployment Targets 51 | 52 | - iOS 14 53 | - tvOS 14 54 | - watchOS 5 55 | - macOS 11 56 | - Xcode 15 (recommended) 57 | 58 | ## Installation 59 | 60 | ### Swift Package Manager 61 | 62 | Add CloudKitCodable to your `Package.swift`: 63 | 64 | ```swift 65 | dependencies: [ 66 | .package(url: "https://github.com/insidegui/CloudKitCodable.git", from: "0.3.0") 67 | ] 68 | ``` 69 | 70 | ### Manually 71 | 72 | If you prefer not to use Swift Package Manager, you can integrate CloudKitCodable into your project manually by copying the files in. 73 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitAssetValue.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CloudKit 3 | import UniformTypeIdentifiers 4 | 5 | /// Adopted by `Codable` types that can be nested in ``CustomCloudKitCodable`` types, represented as `CKAsset` in records. 6 | /// 7 | /// You implement `CloudKitAssetValue` for `Codable` types that can be used as properties of a type conforming to ``CustomCloudKitCodable``. 8 | /// 9 | /// This allows ``CloudKitRecordEncoder`` to encode the nested type as a `CKAsset` containing a (by default) JSON-encoded representation of the value. 10 | /// When decoding with ``CloudKitRecordDecoder``, the local file downloaded from CloudKit is then read and decoded back as the corresponding value. 11 | /// 12 | /// Implementations can customize the encoding/decoding, file type, and asset file name, but there are default implementations for all of this protocol's requirements. 13 | public protocol CloudKitAssetValue: Codable { 14 | 15 | /// The default content type for `CKAsset` files representing values of this type. 16 | /// 17 | /// When using the default implementations of ``CloudKitAssetValue/encoded()`` and ``CloudKitAssetValue/decoded(from:type:)``, 18 | /// the preferred content type determines which encoder/decoder is used for the value: 19 | /// 20 | /// - `.json`: uses `JSONEncoder` and `JSONDecoder` 21 | /// - `.xmlPropertyList`: uses `PropertyListEncoder` (XML) and `PropertyListDecoder` 22 | /// - `.binaryPropertyList`: uses `PropertyListEncoder` (binary) and `PropertyListDecoder` 23 | /// 24 | /// There's a default implementation that returns `.json`, so by default ``CloudKitAssetValue`` types are encoded as JSON. 25 | /// 26 | /// - Important: Changing the content type after you ship a version of your app to production is not recommended, but if you do, ``CloudKitRecordDecoder`` tries to determine the content type 27 | /// based on the asset downloaded from CloudKit, using the declared type as a fallback. 28 | static var preferredContentType: UTType { get } 29 | 30 | /// The file name for this value when being encoded as a `CKAsset`. 31 | /// 32 | /// There's a default implementation for a filename with the format `-.(json/plist)`, 33 | /// and a default implementation for `Identifiable` types that uses the `id` property instead of a random UUID. 34 | var filename: String { get } 35 | 36 | /// Encodes this value as data. 37 | /// 38 | /// There's a default implementation that uses `JSONEncoder`/`PropertyListDecoder` according to the ``preferredContentType`` property. 39 | func encoded() throws -> Data 40 | 41 | /// Decodes an instance of this type from encoded data. 42 | /// - Parameters: 43 | /// - data: The encoded value data. 44 | /// - type: Determines the type of decoder to be used. 45 | /// - Returns: The instance of the type. 46 | /// 47 | /// The default implementation uses `JSONDecoder`/`PropertyListDecoder` depending upon the `type`. 48 | /// For more details, see the documentation for ``preferredContentType-8zbfl``. 49 | static func decoded(from data: Data, type: UTType) throws -> Self 50 | } 51 | 52 | // MARK: - Default Implementations 53 | 54 | public extension CloudKitAssetValue { 55 | /// Default implementation that returns `.json`, so the value is encoded as JSON data. 56 | static var preferredContentType: UTType { .json } 57 | } 58 | 59 | public extension CloudKitAssetValue { 60 | /// The file extension (including the leading `.`), computed according to ``preferredContentType``. 61 | static var filenameSuffix: String { Self.preferredContentType.preferredFilenameExtension.flatMap { ".\($0)" } ?? "" } 62 | 63 | /// The file name (including extension) for this value when encoded into a `CKAsset`. 64 | var filename: String { [String(describing: Self.self), UUID().uuidString].joined(separator: "-") + Self.filenameSuffix } 65 | } 66 | 67 | public extension CloudKitAssetValue where Self: Identifiable { 68 | /// The file name (including extension) for this value when encoded into a `CKAsset`. 69 | /// Uses the `id` property from `Identifiable` conformance. 70 | var filename: String { [String(describing: Self.self), String(describing: id)].joined(separator: "-") + Self.filenameSuffix } 71 | } 72 | 73 | public extension CloudKitAssetValue { 74 | 75 | /// Encodes the nested value. 76 | /// - Returns: The encoded data. 77 | /// 78 | /// This default implementation uses ``preferredContentType-8zbfl`` in order to determine which encoder to use. 79 | /// For more details, see the documentation for ``preferredContentType-8zbfl``. 80 | func encoded() throws -> Data { 81 | let type = Self.preferredContentType 82 | if type.conforms(to: .json) { 83 | return try JSONEncoder.nestedCloudKitValue.encode(self) 84 | } else if type.conforms(to: .xmlPropertyList) { 85 | return try PropertyListEncoder.nestedCloudKitValueXML.encode(self) 86 | } else if type.conforms(to: .binaryPropertyList) { 87 | return try PropertyListEncoder.nestedCloudKitValueBinary.encode(self) 88 | } else if type.conforms(to: .propertyList) { 89 | return try PropertyListEncoder.nestedCloudKitValueXML.encode(self) 90 | } else { 91 | throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Unsupported content type \"\(type.identifier)\": the default implementation only supports JSON and PLIST")) 92 | } 93 | } 94 | 95 | /// Decodes the nested value using data fetched from CloudKit. 96 | /// - Parameters: 97 | /// - data: The encoded data fetched from CloudKit. 98 | /// - type: The `UTType` of the data. 99 | /// - Returns: A decoded instance of the type. 100 | static func decoded(from data: Data, type: UTType) throws -> Self { 101 | if type.conforms(to: .json) { 102 | return try JSONDecoder.nestedCloudKitValue.decode(Self.self, from: data) 103 | } else if type.conforms(to: .propertyList) { 104 | return try PropertyListDecoder.nestedCloudKitValue.decode(Self.self, from: data) 105 | } else { 106 | throw DecodingError.typeMismatch(Self.self, .init(codingPath: [], debugDescription: "Unsupported content type \"\(type.identifier)\": the default implementation only supports JSON and PLIST")) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitEnum.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Base protocol for `enum` types that can be used as properties of types conforming to ``CloudKitRecordRepresentable``. 4 | public protocol CloudKitEnum { 5 | 6 | /// The fallback `enum` `case` to be used when decoding from `CKRecord` encounters an unknown `case`. 7 | /// 8 | /// You implement this in custom enums that can be properties of ``CloudKitRecordRepresentable`` types in order to provide 9 | /// a fallback value when ``CloudKitRecordDecoder`` encounters a raw value that's unknown. 10 | /// 11 | /// This can happen if for example you add more cases to your enum type in an app update. If a user has different versions of your app installed, 12 | /// then it's possible for data on CloudKit to contain raw values that can't be decoded by an older version of the app. 13 | /// 14 | /// - Tip: if you'd like to have the model decoding fail completely if one of its `enum` properties has an unknown raw value, 15 | /// then just return `nil` from your implementation. 16 | static var cloudKitFallbackCase: Self? { get } 17 | } 18 | 19 | public extension CloudKitEnum where Self: CaseIterable { 20 | /// Uses the first `enum` case as the fallback when decoding from `CKRecord` encounters an unknown `case`. 21 | static var cloudKitFallbackCase: Self? { allCases.first } 22 | } 23 | 24 | /// Implemented by `enum` types with `String` raw value that can be used as properties of types conforming to ``CloudKitRecordRepresentable``. 25 | /// 26 | /// See ``CloudKitEnum`` for more details. 27 | public protocol CloudKitStringEnum: Codable, RawRepresentable, CloudKitEnum where RawValue == String { } 28 | 29 | /// Implemented by `enum` types with `Int` raw value that can be used as properties of types conforming to ``CloudKitRecordRepresentable``. 30 | /// 31 | /// See ``CloudKitEnum`` for more details. 32 | public protocol CloudKitIntEnum: Codable, RawRepresentable, CloudKitEnum where RawValue == Int { } 33 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitRecordDecoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitRecordDecoder.swift 3 | // CloudKitCodable 4 | // 5 | // Created by Guilherme Rambo on 12/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | /// A decoder that takes a `CKRecord` and produces a value conforming to ``CustomCloudKitDecodable``. 13 | /// 14 | /// You use an instance of ``CloudKitRecordDecoder`` in order to transform a `CKRecord` downloaded from CloudKit into a value of your custom data type. 15 | final public class CloudKitRecordDecoder { 16 | 17 | /// Decodes a value conforming to ``CustomCloudKitDecodable`` from a `CKRecord` fetched from CloudKit. 18 | /// - Parameters: 19 | /// - type: The type of value. 20 | /// - record: The record that was fetched from CloudKit. 21 | /// - Returns: The decoded value with its properties matching those of the `CKRecord`. 22 | /// 23 | /// Once decoded from a `CKRecord`, your value will have its ``CloudKitRecordRepresentable/cloudKitSystemFields`` set to the corresponding 24 | /// metadata from the `CKRecord`. When encoding the same value again, such as when updating a record, ``CloudKitRecordEncoder`` will use this encoded metadata 25 | /// to produce a record that CloudKit will recognize as being the same "instance". 26 | public func decode(_ type: T.Type, from record: CKRecord) throws -> T where T : Decodable { 27 | let decoder = _CloudKitRecordDecoder(record: record) 28 | return try T(from: decoder) 29 | } 30 | 31 | /// Creates a new instance of the decoder. 32 | /// 33 | /// - Tip: You may safely reuse an instance of ``CloudKitRecordDecoder`` for multiple operations. 34 | public init() { } 35 | } 36 | 37 | final class _CloudKitRecordDecoder { 38 | var codingPath: [CodingKey] = [] 39 | 40 | var userInfo: [CodingUserInfoKey : Any] = [:] 41 | 42 | var container: CloudKitRecordDecodingContainer? 43 | fileprivate var record: CKRecord 44 | 45 | init(record: CKRecord) { 46 | self.record = record 47 | } 48 | } 49 | 50 | extension _CloudKitRecordDecoder: Decoder { 51 | fileprivate func assertCanCreateContainer() { 52 | precondition(self.container == nil) 53 | } 54 | 55 | func container(keyedBy type: Key.Type) -> KeyedDecodingContainer where Key : CodingKey { 56 | assertCanCreateContainer() 57 | 58 | let container = KeyedContainer(record: self.record, codingPath: self.codingPath, userInfo: self.userInfo) 59 | self.container = container 60 | 61 | return KeyedDecodingContainer(container) 62 | } 63 | 64 | func unkeyedContainer() -> UnkeyedDecodingContainer { 65 | fatalError("Not implemented") 66 | } 67 | 68 | func singleValueContainer() -> SingleValueDecodingContainer { 69 | fatalError("Not implemented") 70 | } 71 | } 72 | 73 | protocol CloudKitRecordDecodingContainer: AnyObject { 74 | var codingPath: [CodingKey] { get set } 75 | 76 | var userInfo: [CodingUserInfoKey : Any] { get } 77 | 78 | var record: CKRecord { get set } 79 | } 80 | 81 | extension _CloudKitRecordDecoder { 82 | final class KeyedContainer where Key: CodingKey { 83 | var record: CKRecord 84 | var codingPath: [CodingKey] 85 | var userInfo: [CodingUserInfoKey: Any] 86 | 87 | private lazy var systemFieldsData: Data = { 88 | return decodeSystemFields() 89 | }() 90 | 91 | func nestedCodingPath(forKey key: CodingKey) -> [CodingKey] { 92 | return self.codingPath + [key] 93 | } 94 | 95 | init(record: CKRecord, codingPath: [CodingKey], userInfo: [CodingUserInfoKey : Any]) { 96 | self.codingPath = codingPath 97 | self.userInfo = userInfo 98 | self.record = record 99 | } 100 | 101 | func checkCanDecodeValue(forKey key: Key) throws { 102 | guard self.contains(key) else { 103 | let context = DecodingError.Context(codingPath: self.codingPath, debugDescription: "key not found: \(key)") 104 | throw DecodingError.keyNotFound(key, context) 105 | } 106 | } 107 | } 108 | } 109 | 110 | extension _CloudKitRecordDecoder.KeyedContainer: KeyedDecodingContainerProtocol { 111 | var allKeys: [Key] { 112 | return self.record.allKeys().compactMap { Key(stringValue: $0) } 113 | } 114 | 115 | func contains(_ key: Key) -> Bool { 116 | guard key.stringValue != _CKSystemFieldsKeyName else { return true } 117 | 118 | return allKeys.contains(where: { $0.stringValue == key.stringValue }) 119 | } 120 | 121 | func decodeNil(forKey key: Key) throws -> Bool { 122 | try checkCanDecodeValue(forKey: key) 123 | 124 | if key.stringValue == _CKSystemFieldsKeyName { 125 | return systemFieldsData.count == 0 126 | } else { 127 | return record[key.stringValue] == nil 128 | } 129 | } 130 | 131 | func decode(_ type: T.Type, forKey key: Key) throws -> T where T : Decodable { 132 | try checkCanDecodeValue(forKey: key) 133 | 134 | if key.stringValue == _CKSystemFieldsKeyName { 135 | return systemFieldsData as! T 136 | } 137 | 138 | if key.stringValue == _CKIdentifierKeyName { 139 | return record.recordID.recordName as! T 140 | } 141 | 142 | // Bools are encoded as Int64 in CloudKit 143 | if type == Bool.self { 144 | return try decodeBool(forKey: key) as! T 145 | } 146 | 147 | // URLs are encoded as String (remote) or CKAsset (file URL) in CloudKit 148 | if type == URL.self { 149 | return try decodeURL(forKey: key) as! T 150 | } 151 | 152 | func typeMismatch(_ message: String) -> DecodingError { 153 | let context = DecodingError.Context( 154 | codingPath: codingPath, 155 | debugDescription: message 156 | ) 157 | return DecodingError.typeMismatch(type, context) 158 | } 159 | 160 | if let stringEnumType = T.self as? any CloudKitStringEnum.Type { 161 | guard let stringValue = record[key.stringValue] as? String else { 162 | throw typeMismatch("Expected to decode a rawValue String for \"\(String(describing: type))\"") 163 | } 164 | guard let enumValue = stringEnumType.init(rawValue: stringValue) ?? stringEnumType.cloudKitFallbackCase else { 165 | #if DEBUG 166 | throw typeMismatch("Failed to construct enum \"\(String(describing: type))\" from String \"\(stringValue)\"") 167 | #else 168 | throw typeMismatch("Failed to construct enum \"\(String(describing: type))\" from String value") 169 | #endif 170 | } 171 | return enumValue as! T 172 | } 173 | 174 | if let intEnumType = T.self as? any CloudKitIntEnum.Type { 175 | guard let intValue = record[key.stringValue] as? Int else { 176 | throw typeMismatch("Expected to decode a rawValue Int for \"\(String(describing: type))\"") 177 | } 178 | guard let enumValue = intEnumType.init(rawValue: intValue) ?? intEnumType.cloudKitFallbackCase else { 179 | throw typeMismatch("Failed to construct enum \"\(String(describing: type))\" from value \"\(intValue)\"") 180 | } 181 | return enumValue as! T 182 | } 183 | 184 | /// This will attempt to JSON-decode child values for `Data` fields, but it's important to check that the type of the field 185 | /// is not `Data`, otherwise we'd be trying to decode JSON from any data field, even those that do not contain JSON-encoded children. 186 | if T.self != Data.self, 187 | let nestedData = record[key.stringValue] as? Data 188 | { 189 | let value = try JSONDecoder.nestedCloudKitValue.decode(T.self, from: nestedData) 190 | 191 | return value 192 | } else if let customAssetType = type as? CloudKitAssetValue.Type { 193 | guard let ckAsset = record[key.stringValue] as? CKAsset else { 194 | throw typeMismatch("CKRecord value for CloudKitAssetValue field must be a CKAsset") 195 | } 196 | 197 | let value = try decodeCustomAsset(customAssetType, from: ckAsset, key: key) 198 | 199 | return value as! T 200 | } else { 201 | guard let value = record[key.stringValue] as? T else { 202 | throw typeMismatch("CKRecordValue couldn't be converted to \"\(String(describing: type))\"") 203 | } 204 | 205 | return value 206 | } 207 | } 208 | 209 | private func decodeURL(forKey key: Key) throws -> URL { 210 | if let asset = record[key.stringValue] as? CKAsset { 211 | return try decodeURL(from: asset) 212 | } 213 | 214 | guard let str = record[key.stringValue] as? String else { 215 | let context = DecodingError.Context(codingPath: codingPath, debugDescription: "URL should have been encoded as String in CKRecord") 216 | throw DecodingError.typeMismatch(URL.self, context) 217 | } 218 | 219 | guard let url = URL(string: str) else { 220 | let context = DecodingError.Context(codingPath: codingPath, debugDescription: "The string \(str) is not a valid URL") 221 | throw DecodingError.typeMismatch(URL.self, context) 222 | } 223 | 224 | return url 225 | } 226 | 227 | private func decodeCustomAsset(_ type: T.Type, from asset: CKAsset, key: Key) throws -> T { 228 | guard let url = asset.fileURL else { 229 | throw DecodingError.dataCorruptedError(forKey: key, in: self, debugDescription: "CKAsset has no fileURL") 230 | } 231 | 232 | let contentType = (try url.resourceValues(forKeys: [.contentTypeKey])).contentType ?? T.preferredContentType 233 | 234 | let data = try Data(contentsOf: url) 235 | 236 | /// Don't use dynamic content type (which is the type when `URL` can't figure out its type). 237 | let effectiveType = contentType.isDynamic ? T.preferredContentType : contentType 238 | return try T.decoded(from: data, type: effectiveType) 239 | } 240 | 241 | private func decodeURL(from asset: CKAsset) throws -> URL { 242 | guard let url = asset.fileURL else { 243 | let context = DecodingError.Context(codingPath: codingPath, debugDescription: "URL value not found") 244 | throw DecodingError.valueNotFound(URL.self, context) 245 | } 246 | 247 | return url 248 | } 249 | 250 | private func decodeBool(forKey key: Key) throws -> Bool { 251 | guard let intValue = record[key.stringValue] as? Int64 else { 252 | let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Bool should have been encoded as Int64 in CKRecord") 253 | throw DecodingError.typeMismatch(Bool.self, context) 254 | } 255 | 256 | return intValue == 1 257 | } 258 | 259 | private func decodeSystemFields() -> Data { 260 | let coder = NSKeyedArchiver.init(requiringSecureCoding: true) 261 | record.encodeSystemFields(with: coder) 262 | coder.finishEncoding() 263 | 264 | return coder.encodedData 265 | } 266 | 267 | func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { 268 | fatalError("Not implemented") 269 | } 270 | 271 | func nestedContainer(keyedBy type: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer where NestedKey : CodingKey { 272 | fatalError("Not implemented") 273 | } 274 | 275 | func superDecoder() throws -> Decoder { 276 | return _CloudKitRecordDecoder(record: record) 277 | } 278 | 279 | func superDecoder(forKey key: Key) throws -> Decoder { 280 | let decoder = _CloudKitRecordDecoder(record: self.record) 281 | decoder.codingPath = [key] 282 | 283 | return decoder 284 | } 285 | } 286 | 287 | extension _CloudKitRecordDecoder.KeyedContainer: CloudKitRecordDecodingContainer {} 288 | 289 | extension JSONDecoder { 290 | static let nestedCloudKitValue = JSONDecoder() 291 | } 292 | 293 | extension PropertyListDecoder { 294 | static let nestedCloudKitValue = PropertyListDecoder() 295 | } 296 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CloudKitRecordEncoder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitRecordEncoder.swift 3 | // CloudKitCodable 4 | // 5 | // Created by Guilherme Rambo on 11/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | /// Errors that can occur when encoding a custom type to `CKRecord`. 13 | public enum CloudKitRecordEncodingError: Error { 14 | /// A given model property contains a value that can't be encoded into the `CKRecord`. 15 | /// 16 | /// To learn more about supported data types and limitations, see . 17 | case unsupportedValueForKey(String) 18 | /// The existing value in ``CloudKitRecordRepresentable/cloudKitSystemFields`` couldn't be decoded 19 | /// for constructing an updated version of the corresponding `CKRecord`. 20 | case systemFieldsDecode(String) 21 | @available(*, deprecated, message: "This is no longer thrown and is kept for source compatibility.") 22 | case referencesNotSupported(String) 23 | /// A `Data` property or a nested `Codable` value ended up being too large for encoding into a `CKRecord`. 24 | /// 25 | /// - Tip: If you're experiencing this error when encoding a model that has a nested `Codable` property, 26 | /// consider adopting ``CloudKitAssetValue`` so that the property can be encoded as a `CKAsset` instead. 27 | case dataFieldTooLarge(key: String, size: Int) 28 | 29 | /// A description of the encoding error. 30 | public var localizedDescription: String { 31 | switch self { 32 | case .unsupportedValueForKey(let key): 33 | return """ 34 | The value of key \(key) is not supported. Only values that can be converted to 35 | CKRecordValue are supported. Check the CloudKit documentation to see which types 36 | can be used. 37 | """ 38 | case .systemFieldsDecode(let info): 39 | return "Failed to process \(_CKSystemFieldsKeyName): \(info)" 40 | case .referencesNotSupported(let key): 41 | return "References are not supported by CloudKitRecordEncoder yet. Key: \(key)." 42 | case .dataFieldTooLarge(let key, let size): 43 | return "Value for child data \"\(key)\" of \(size) bytes exceeds maximum of \(CKRecord.maxDataSize) bytes" 44 | } 45 | } 46 | } 47 | 48 | /// An encoder that takes a value conforming to ``CustomCloudKitEncodable`` and produces a `CKRecord`. 49 | /// 50 | /// You use an instance of ``CloudKitRecordEncoder`` in order to transform your custom data type into a `CKRecord` before uploading it to CloudKit. 51 | public class CloudKitRecordEncoder { 52 | 53 | /// The CloudKit zone identifier that will be associated with the record created by this encoder. 54 | /// 55 | /// - Note: This property is ignored when encoding a value with its ``CloudKitRecordRepresentable/cloudKitSystemFields`` property set. 56 | /// When that's the case, the zone ID is read from the record metadata encoded in the system fields. 57 | public var zoneID: CKRecordZone.ID? 58 | 59 | /// Encodes a value conforming to ``CustomCloudKitEncodable``, turning it into a `CKRecord`. 60 | /// - Parameter value: The value to be encoded. 61 | /// - Returns: A `CKRecord` representing the value. 62 | /// 63 | /// Your custom data type that conforms to ``CustomCloudKitEncodable`` is turned into a `CKRecord` where each record field 64 | /// represents a property of your type, according to its `CodingKeys`. 65 | /// 66 | /// If the encoder is initialized with a ``zoneID``, then the ID of the `CKRecord` will include that zone ID. 67 | /// 68 | /// When encoding a value that's already been through the CloudKit servers, its ``CloudKitRecordRepresentable/cloudKitSystemFields`` should be available, 69 | /// in which case the encoder will construct a `CKRecord` with the metadata corresponding to the record on the server. 70 | public func encode(_ value: Encodable) throws -> CKRecord { 71 | let type = recordTypeName(for: value) 72 | let name = recordName(for: value) 73 | 74 | let encoder = _CloudKitRecordEncoder(recordTypeName: type, zoneID: zoneID, recordName: name) 75 | 76 | try value.encode(to: encoder) 77 | 78 | return encoder.record 79 | } 80 | 81 | private func recordTypeName(for value: Encodable) -> String { 82 | if let customValue = value as? CustomCloudKitEncodable { 83 | return customValue.cloudKitRecordType 84 | } else { 85 | return String(describing: type(of: value)) 86 | } 87 | } 88 | 89 | private func recordName(for value: Encodable) -> String { 90 | if let customValue = value as? CustomCloudKitEncodable { 91 | return customValue.cloudKitIdentifier 92 | } else { 93 | return UUID().uuidString 94 | } 95 | } 96 | 97 | /// Initializes the encoder. 98 | /// - Parameter zoneID: If provided, the `CKRecord` produced will have its record ID 99 | /// set to the specified zone. Uses the default CloudKit zone if the zone is not specified. 100 | /// 101 | /// - Tip: You may safely reuse an instance of ``CloudKitRecordEncoder`` for multiple operations. 102 | public init(zoneID: CKRecordZone.ID? = nil) { 103 | self.zoneID = zoneID 104 | } 105 | } 106 | 107 | final class _CloudKitRecordEncoder { 108 | let zoneID: CKRecordZone.ID? 109 | let recordTypeName: String 110 | let recordName: String 111 | 112 | init(recordTypeName: String, zoneID: CKRecordZone.ID?, recordName: String) { 113 | self.recordTypeName = recordTypeName 114 | self.zoneID = zoneID 115 | self.recordName = recordName 116 | } 117 | 118 | var codingPath: [CodingKey] = [] 119 | 120 | var userInfo: [CodingUserInfoKey : Any] = [:] 121 | 122 | fileprivate var container: CloudKitRecordEncodingContainer? 123 | } 124 | 125 | extension CodingUserInfoKey { 126 | static let targetRecord = CodingUserInfoKey(rawValue: "TargetRecord")! 127 | } 128 | 129 | extension _CloudKitRecordEncoder: Encoder { 130 | var record: CKRecord { 131 | if let existingRecord = container?.record { return existingRecord } 132 | 133 | let zid = zoneID ?? CKRecordZone.ID(zoneName: CKRecordZone.ID.defaultZoneName, ownerName: CKCurrentUserDefaultName) 134 | let rid = CKRecord.ID(recordName: recordName, zoneID: zid) 135 | 136 | return CKRecord(recordType: recordTypeName, recordID: rid) 137 | } 138 | 139 | fileprivate func assertCanCreateContainer() { 140 | precondition(self.container == nil) 141 | } 142 | 143 | func container(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { 144 | assertCanCreateContainer() 145 | 146 | let container = KeyedContainer(recordTypeName: self.recordTypeName, 147 | zoneID: self.zoneID, 148 | recordName: self.recordName, 149 | codingPath: self.codingPath, 150 | userInfo: self.userInfo) 151 | self.container = container 152 | 153 | return KeyedEncodingContainer(container) 154 | } 155 | 156 | func unkeyedContainer() -> UnkeyedEncodingContainer { 157 | fatalError("Not implemented") 158 | } 159 | 160 | func singleValueContainer() -> SingleValueEncodingContainer { 161 | fatalError("Not implemented") 162 | } 163 | } 164 | 165 | protocol CloudKitRecordEncodingContainer: AnyObject { 166 | var record: CKRecord? { get } 167 | } 168 | 169 | extension _CloudKitRecordEncoder { 170 | final class KeyedContainer where Key: CodingKey { 171 | let recordTypeName: String 172 | let zoneID: CKRecordZone.ID? 173 | let recordName: String 174 | var metaRecord: CKRecord? 175 | var codingPath: [CodingKey] 176 | var userInfo: [CodingUserInfoKey: Any] 177 | 178 | fileprivate var storage: [String: CKRecordValue] = [:] 179 | 180 | init(recordTypeName: String, 181 | zoneID: CKRecordZone.ID?, 182 | recordName: String, 183 | codingPath: [CodingKey], 184 | userInfo: [CodingUserInfoKey : Any]) 185 | { 186 | self.recordTypeName = recordTypeName 187 | self.zoneID = zoneID 188 | self.recordName = recordName 189 | self.codingPath = codingPath 190 | self.userInfo = userInfo 191 | } 192 | } 193 | } 194 | 195 | extension _CloudKitRecordEncoder.KeyedContainer: KeyedEncodingContainerProtocol { 196 | func encodeNil(forKey key: Key) throws { 197 | storage[key.stringValue] = nil 198 | } 199 | 200 | func encode(_ value: T, forKey key: Key) throws where T : Encodable { 201 | guard key.stringValue != _CKSystemFieldsKeyName else { 202 | guard let systemFields = value as? Data else { 203 | throw CloudKitRecordEncodingError.systemFieldsDecode("\(_CKSystemFieldsKeyName) property must be of type Data") 204 | } 205 | 206 | try prepareMetaRecord(with: systemFields) 207 | 208 | return 209 | } 210 | 211 | storage[key.stringValue] = try produceCloudKitValue(for: value, withKey: key) 212 | } 213 | 214 | private func produceCloudKitValue(for value: T, withKey key: Key) throws -> CKRecordValue where T : Encodable { 215 | if let urlValue = value as? URL { 216 | return produceCloudKitValue(for: urlValue) 217 | } else if let collection = value as? [Any] { 218 | /// The `value as? CKRecordValue` cast in the next `else if` will always succeed for arrays, 219 | /// so here we check that the value is actually an array where the elements conform to `CKRecordValue`, 220 | /// then return it as an `NSArray`. Otherwise, this is an array with arbitrary `Encodable` elements, 221 | /// in which case they'll be stored as a single data field with the JSON-encoded representation. 222 | if let ckValueArray = collection as? [CKRecordValue] { 223 | return ckValueArray as NSArray 224 | } else { 225 | return try encodedChildValue(for: value, withKey: key) 226 | } 227 | } else if let ckValue = value as? CKRecordValue { 228 | return ckValue 229 | } else if let stringValue = (value as? any CloudKitStringEnum)?.rawValue { 230 | return stringValue as NSString 231 | } else if let intValue = (value as? any CloudKitIntEnum)?.rawValue { 232 | return NSNumber(value: Int(intValue)) 233 | } else { 234 | return try encodedChildValue(for: value, withKey: key) 235 | } 236 | } 237 | 238 | private func encodedChildValue(for value: T, withKey key: Key) throws -> CKRecordValue where T : Encodable { 239 | if let customAssetValue = value as? CloudKitAssetValue { 240 | let asset = try customAssetValue.createAsset() 241 | return asset 242 | } else { 243 | let encodedChild = try JSONEncoder.nestedCloudKitValue.encode(value) 244 | 245 | guard encodedChild.count < CKRecord.maxDataSize else { 246 | throw CloudKitRecordEncodingError.dataFieldTooLarge(key: key.stringValue, size: encodedChild.count) 247 | } 248 | 249 | return encodedChild as NSData 250 | } 251 | } 252 | 253 | private func prepareMetaRecord(with systemFields: Data) throws { 254 | let coder = try NSKeyedUnarchiver(forReadingFrom: systemFields) 255 | coder.requiresSecureCoding = true 256 | metaRecord = CKRecord(coder: coder) 257 | coder.finishDecoding() 258 | } 259 | 260 | private func produceCloudKitValue(for url: URL) -> CKRecordValue { 261 | if url.isFileURL { 262 | return CKAsset(fileURL: url) 263 | } else { 264 | return url.absoluteString as CKRecordValue 265 | } 266 | } 267 | 268 | func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { 269 | fatalError("Not implemented") 270 | } 271 | 272 | func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { 273 | fatalError("Not implemented") 274 | } 275 | 276 | func superEncoder() -> Encoder { 277 | fatalError("Not implemented") 278 | } 279 | 280 | func superEncoder(forKey key: Key) -> Encoder { 281 | fatalError("Not implemented") 282 | } 283 | } 284 | 285 | extension _CloudKitRecordEncoder.KeyedContainer: CloudKitRecordEncodingContainer { 286 | 287 | var recordID: CKRecord.ID { 288 | let zid = zoneID ?? CKRecordZone.ID(zoneName: CKRecordZone.ID.defaultZoneName, ownerName: CKCurrentUserDefaultName) 289 | return CKRecord.ID(recordName: recordName, zoneID: zid) 290 | } 291 | 292 | var record: CKRecord? { 293 | let output: CKRecord 294 | 295 | if let metaRecord = self.metaRecord { 296 | output = metaRecord 297 | } else { 298 | output = CKRecord(recordType: recordTypeName, recordID: recordID) 299 | } 300 | 301 | guard output.recordType == recordTypeName else { 302 | fatalError( 303 | """ 304 | CloudKit record type mismatch: the record should be of type \(recordTypeName) but it was 305 | of type \(output.recordType). This is probably a result of corrupted cloudKitSystemData 306 | or a change in record/type name that must be corrected in your type by adopting CustomCloudKitEncodable. 307 | """ 308 | ) 309 | } 310 | 311 | for (key, value) in storage { 312 | output[key] = value 313 | } 314 | 315 | return output 316 | } 317 | 318 | } 319 | 320 | extension JSONEncoder { 321 | static let nestedCloudKitValue: JSONEncoder = { 322 | let e = JSONEncoder() 323 | e.outputFormatting = [.withoutEscapingSlashes, .sortedKeys] 324 | return e 325 | }() 326 | } 327 | 328 | extension PropertyListEncoder { 329 | static let nestedCloudKitValueBinary: PropertyListEncoder = { 330 | let e = PropertyListEncoder() 331 | e.outputFormat = .binary 332 | return e 333 | }() 334 | 335 | static let nestedCloudKitValueXML: PropertyListEncoder = { 336 | let e = PropertyListEncoder() 337 | e.outputFormat = .xml 338 | return e 339 | }() 340 | } 341 | 342 | private extension CKRecord { 343 | /// The entire `CKRecord` can't exceed 1MB, but since we don't really know how large the whole 344 | /// record is, we just check data fields to ensure that they fit within the limit. This doesn't prevent 345 | /// the record from exceeding the 1MB limit, but at least catches the most egregious attempts. 346 | static let maxDataSize = 1_000_000 347 | } 348 | 349 | // MARK: - CloudKitAssetValue Support 350 | 351 | private extension CloudKitAssetValue { 352 | func createAsset() throws -> CKAsset { 353 | let data = try encoded() 354 | 355 | let tempURL = FileManager.default.temporaryDirectory 356 | .appendingPathComponent(filename) 357 | 358 | try data.write(to: tempURL) 359 | 360 | return CKAsset(fileURL: tempURL) 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/CustomCloudKitEncodable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomCloudKitEncodable.swift 3 | // CloudKitCodable 4 | // 5 | // Created by Guilherme Rambo on 11/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKit 11 | 12 | internal let _CKSystemFieldsKeyName = "cloudKitSystemFields" 13 | internal let _CKIdentifierKeyName = "cloudKitIdentifier" 14 | 15 | /// Base protocol for types that can be represented as a `CKRecord`. 16 | /// 17 | /// This protocol is the base for both ``CustomCloudKitEncodable`` and ``CustomCloudKitDecodable``. 18 | /// 19 | /// Its only requirement that doesn't have a default implementation is ``CloudKitRecordRepresentable/cloudKitSystemFields``, which is a required 20 | /// property for all types that can be encoded as a `CKRecord` and decoded from a `CKRecord`. 21 | /// 22 | /// The ``CloudKitRecordRepresentable/cloudKitRecordType`` and ``CloudKitRecordRepresentable/cloudKitIdentifier`` 23 | /// allow you to customize the CloudKit record type and record name. 24 | /// 25 | /// - note: You probably don't want to conform to `CloudKitRecordRepresentable` by itself. 26 | /// Declare conformance to ``CustomCloudKitCodable`` instead, which will include the requirements from this protocol, 27 | /// as well as support for encoding/decoding. 28 | public protocol CloudKitRecordRepresentable { 29 | 30 | /// Stores metadata about the `CKRecord` for this value. 31 | /// 32 | /// After a `CKRecord` is uploaded to CloudKit, or when a `CKRecord` is initially downloaded from CloudKit, 33 | /// decoding it with ``CloudKitRecordDecoder`` will populate this property with metadata about the `CKRecord`. 34 | /// 35 | /// If you're using CloudKit's sync functionality, then you want to keep this metadata around so that new instances of `CKRecord` 36 | /// created from the same value of your custom data type are recognized by CloudKit as being the same "instance" of that record type. 37 | /// 38 | /// - Important: If you're using CloudKit to keep local and remote data in sync between devices, then it's extremely important that you 39 | /// store this data with your model, be it on a database, the filesystem, or wherever you're storing local data. Doing this ensures that CloudKit's sync 40 | /// functionality will recognize the model across devices and allow for conflict resolution, preventing issues with duplicated records or data getting out of sync. 41 | /// If you're just storing/retrieving data on the public database or just not using CloudKit's advanced sync capabilities, then it's less important to keep this metadata around. 42 | /// Think of whether you're going to be uploading the same "instance" of your model to CloudKit multiple times, for example to update some of its properties. 43 | /// If that's the case, then you should make sure that this metadata is present when encoding your updated model prior to uploading it to CloudKit again. 44 | var cloudKitSystemFields: Data? { get } 45 | 46 | /// The `recordType` for this type when encoded as a `CKRecord`. 47 | /// 48 | /// When you encode a custom data type into a `CKRecord` with ``CloudKitRecordEncoder``, 49 | /// the encoder uses the value of this property when constructing the `CKRecord`, passing it as the record's `recordType`. 50 | /// 51 | /// **Default implementation**: ``cloudKitRecordType-79t3x`` 52 | var cloudKitRecordType: String { get } 53 | 54 | /// The `recordName` for this type when encoded as a `CKRecord`. 55 | /// 56 | /// When you encode a custom data type into a `CKRecord` with ``CloudKitRecordEncoder``, 57 | /// the encoder uses the value of this property for its `recordName`, which is the canonical identifier for a record on CloudKit. 58 | /// 59 | /// If you already have an identifier for your model, then you'll probably want to implement this and return the value for that identifier, 60 | /// so that it's easier to match between local values and their corresponding `CKRecord` on CloudKit. 61 | var cloudKitIdentifier: String { get } 62 | } 63 | 64 | public extension CloudKitRecordRepresentable { 65 | 66 | /// The `recordType` using the type's name. 67 | /// 68 | /// This default implementation uses the name of your type as the `recordType` when encoding it as a `CKRecord`. 69 | /// 70 | /// For example, if you have a `Person` type, then the `recordType` of a `CKRecord` representing an instance of `Person` 71 | /// will be — you guessed it — `Person`. 72 | var cloudKitRecordType: String { 73 | return String(describing: type(of: self)) 74 | } 75 | 76 | /// A random `UUID` to be used as the `recordName`. 77 | /// 78 | /// This default implementation generates a random `UUID` that's used as the `recordName` of a `CKRecord` when encoding your type. 79 | /// 80 | /// - note: If you already have a unique identifier for your data type, then you probably want to implement this property, returning your existing identifier. 81 | /// 82 | /// **Default implementation**: ``cloudKitIdentifier-uk1q`` 83 | var cloudKitIdentifier: String { 84 | return UUID().uuidString 85 | } 86 | } 87 | 88 | /// Implemented by types that can be encoded into `CKRecord` with ``CloudKitRecordEncoder``. 89 | /// 90 | /// See ``CloudKitRecordRepresentable`` for details. 91 | public protocol CustomCloudKitEncodable: CloudKitRecordRepresentable & Encodable { } 92 | 93 | /// Implemented by types that can be decoded from a `CKRecord` with ``CloudKitRecordDecoder``. 94 | /// 95 | /// See ``CloudKitRecordRepresentable`` for details. 96 | public protocol CustomCloudKitDecodable: CloudKitRecordRepresentable & Decodable { } 97 | 98 | /// Implemented by types that can be encoded and decoded to/from `CKRecord` with ``CloudKitRecordEncoder`` and ``CloudKitRecordDecoder``. 99 | /// 100 | /// See ``CloudKitRecordRepresentable`` for details. 101 | public protocol CustomCloudKitCodable: CustomCloudKitEncodable & CustomCloudKitDecodable { } 102 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/Documentation.docc/CloudKitCodable.md: -------------------------------------------------------------------------------- 1 | # ``CloudKitCodable`` 2 | 3 | This library provides encoding and decoding of custom value types to and from `CKRecord`, making it a lot easier to transfer custom data types between your app and CloudKit. 4 | 5 | ## Overview 6 | 7 | To make a type `CKRecord`-compatible, you implement the ``CustomCloudKitCodable`` protocol, which is composed of the ``CustomCloudKitEncodable`` and ``CustomCloudKitDecodable`` protocols. 8 | 9 | Encoding and decoding uses the same mechanism as `Codable`, but instead of using something like `JSONEncoder` and `JSONDecoder`, you use ``CloudKitRecordEncoder`` and ``CloudKitRecordDecoder``. 10 | The encoder takes your custom data type as input and produces a corresponding `CKRecord`, and the decoder takes an existing `CKRecord` and produces an instance of your custom type. 11 | 12 | ## Quick Start 13 | 14 | For a simple example, see . 15 | 16 | To familiarize yourself with how different data types are encoded and decoded, see . 17 | 18 | ## Topics 19 | 20 | ### Implementing Support for `CKRecord` in Your Models 21 | 22 | - ``CustomCloudKitCodable`` 23 | 24 | ### Encoding and Decoding Records 25 | 26 | - ``CloudKitRecordEncoder`` 27 | - ``CloudKitRecordDecoder`` 28 | 29 | ### Supporting Custom Types and Assets 30 | 31 | - ``CloudKitStringEnum`` 32 | - ``CloudKitIntEnum`` 33 | - ``CloudKitAssetValue`` 34 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/Documentation.docc/DataTypes.md: -------------------------------------------------------------------------------- 1 | # Data Types 2 | 3 | How CloudKitCodable handles different data types when dealing with `CKRecord` encoding/decoding. 4 | 5 | CloudKit [imposes some limits](https://developer.apple.com/documentation/cloudkit/ckrecord) on what data types can be stored in a `CKRecord`, as well as the maximum size for all data associated with an individual record. 6 | 7 | CloudKitCodable tries to handle most of these limitations automatically, but if you're looking to encode and decode complex types to/from `CKRecord`, then there's additional work you can do to make sure that everything works correctly. 8 | 9 | ## Primitive Types 10 | 11 | Simple types such as `String` and `Int` work as you would expect: they're simply stored in the `CKRecord` as-is. 12 | 13 | ## URL 14 | 15 | There's no native support for `URL` in `CKRecord`, and CloudKitCodable handles URLs differently depending upon whether the `URL` is a local file URL, or a remote web URL. 16 | 17 | ### Local File URLs 18 | 19 | When ``CloudKitRecordEncoder`` encounters a property with a `URL` pointing to a local file, the corresponding property on the `CKRecord` will be encoded as a [CKAsset](https://developer.apple.com/documentation/cloudkit/ckasset). 20 | 21 | So in order to upload a file to CloudKit, you can have the corresponding property be a `URL` pointing to the local file, and it will be uploaded when saving the record. 22 | 23 | The opposite occurs when decoding a record downloaded from CloudKit: ``CloudKitRecordDecoder`` will find the `CKAsset` and set the `URL` property to point to the local file URL downloaded by CloudKit. 24 | 25 | ### Remote Web URLs 26 | 27 | When the `URL` property being encoded has a web URL such as `https://apple.com`, it will be encoded into the `CKRecord` as a `String` containing its absolute string. Upon decoding, the string is then parsed as a `URL`. 28 | 29 | ### Custom Enumerations 30 | 31 | CloudKitCodable can encode and decode `enum` properties that are backed by either a `String` or `Int` raw value. 32 | 33 | In order for the enum encoding to work, your enum must conform to ``CloudKitStringEnum`` or ``CloudKitIntEnum``, the only requirement being a static ``CloudKitEnum/cloudKitFallbackCase`` property that determines the default value for the property in case the `CKRecord` contains a raw value that can't initialize the enum. 34 | 35 | ### Nested Codable Values 36 | 37 | Sometimes models have properties that use small value types with a few of their own properties, and you might want to store such models on CloudKit as well. 38 | 39 | To enable this, CloudKitCodable will detect properties that have a custom `Codable` type and set the corresponding `CKRecord` field to be a `Data` value with the JSON-encoded representation of the value. 40 | 41 | > Important: If your model has a property with a `Codable` type that can potentially become large when encoded, or if your model has more than a couple of properties with `Codable` types, then you should adopt ``CloudKitAssetValue`` instead so that the properties can be represented as a `CKAsset`, which doesn't run the risk of bumping into the 1MB per-record size limit. 42 | 43 | ### Arrays 44 | 45 | All primitive types that are supported in `CKRecord` array fields are also supported by CloudKitCodable. 46 | 47 | Nested codable values can also be stored as an array, which will become an array of JSON-encoded `Data` in the `CKRecord`. 48 | 49 | > Note: Types conforming to ``CloudKitAssetValue`` **are not** currently supported in arrays. 50 | 51 | > Note: Collection types other than `Array` are not supported. 52 | -------------------------------------------------------------------------------- /Sources/CloudKitCodable/Documentation.docc/Example.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | Simple example of how to implement support for `CKRecord` in a custom data type. 4 | 5 | Here's an example of a custom data type that implements ``CustomCloudKitCodable``: 6 | 7 | ```swift 8 | struct Person: CustomCloudKitCodable { 9 | var cloudKitSystemFields: Data? 10 | let name: String 11 | let age: Int 12 | let website: URL 13 | let avatar: URL 14 | let isDeveloper: Bool 15 | } 16 | ``` 17 | 18 | The only requirement I had to implement in this example was the ``CloudKitRecordRepresentable/cloudKitSystemFields`` property, which is used to store metadata from CloudKit that's fetched alongside the `CKRecord`. 19 | 20 | If you plan on using your model as a way to sync user data with CloudKit, then you're probably storing it locally using something like a database. If that's the case, then it's important that you also store the value of `cloudKitSystemFields` after fetching a record from CloudKit, or after uploading the model for the first time. That way CloudKit can keep track of the data and allow you to address issues such as sync conflicts. 21 | 22 | Let's say I want to upload a `Person` record to CloudKit, this is how I would do it: 23 | 24 | ```swift 25 | let rambo = Person( 26 | cloudKitSystemFields: nil, 27 | name: "Guilherme Rambo", 28 | age: 32, 29 | website: URL(string:"https://rambo.codes")!, 30 | avatar: URL(fileURLWithPath: "/Users/inside/Pictures/avatar.png"), 31 | isDeveloper: true 32 | ) 33 | 34 | do { 35 | let record = try CloudKitRecordEncoder().encode(rambo) 36 | // record is now a CKRecord you can upload to CloudKit 37 | } catch { 38 | // something went wrong 39 | } 40 | ``` 41 | 42 | This is how I would decode a `CKRecord` representing a `Person`: 43 | 44 | ```swift 45 | let record = // record obtained from CloudKit 46 | do { 47 | let person = try CloudKitRecordDecoder().decode(Person.self, from: record) 48 | } catch { 49 | // something went wrong 50 | } 51 | ``` 52 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/CloudKitRecordDecoderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitRecordDecoderTests.swift 3 | // CloudKitCodableTests 4 | // 5 | // Created by Guilherme Rambo on 12/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | @testable import CloudKitCodable 12 | 13 | final class CloudKitRecordDecoderTests: XCTestCase { 14 | 15 | private func _validateDecodedPerson(_ person: Person) { 16 | XCTAssertEqual(person, Person.rambo) 17 | XCTAssertNotNil(person.cloudKitSystemFields, "\(_CKSystemFieldsKeyName) should bet set for a value conforming to CloudKitRecordRepresentable decoded from an existing CKRecord") 18 | } 19 | 20 | func testComplexPersonStructDecoding() throws { 21 | let person = try CloudKitRecordDecoder().decode(Person.self, from: CKRecord.testRecord) 22 | 23 | _validateDecodedPerson(person) 24 | } 25 | 26 | func testRoundTrip() throws { 27 | let encodedPerson = try CloudKitRecordEncoder().encode(Person.rambo) 28 | let samePersonDecoded = try CloudKitRecordDecoder().decode(Person.self, from: encodedPerson) 29 | 30 | _validateDecodedPerson(samePersonDecoded) 31 | } 32 | 33 | func testRoundTripWithCustomZoneID() throws { 34 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: CKCurrentUserDefaultName) 35 | let encodedPerson = try CloudKitRecordEncoder(zoneID: zoneID).encode(Person.rambo) 36 | let samePersonDecoded = try CloudKitRecordDecoder().decode(Person.self, from: encodedPerson) 37 | let samePersonReencoded = try CloudKitRecordEncoder().encode(samePersonDecoded) 38 | 39 | _validateDecodedPerson(samePersonDecoded) 40 | 41 | XCTAssert(encodedPerson.recordID.zoneID == samePersonReencoded.recordID.zoneID) 42 | } 43 | 44 | func testCustomRecordIdentifierRoundTrip() throws { 45 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: CKCurrentUserDefaultName) 46 | 47 | let record = try CloudKitRecordEncoder(zoneID: zoneID).encode(PersonWithCustomIdentifier.rambo) 48 | 49 | XCTAssert(record.recordID.zoneID == zoneID) 50 | XCTAssert(record.recordID.recordName == "MY-ID") 51 | 52 | let samePersonDecoded = try CloudKitRecordDecoder().decode(PersonWithCustomIdentifier.self, from: record) 53 | XCTAssert(samePersonDecoded.cloudKitIdentifier == "MY-ID") 54 | } 55 | 56 | func testEnumRoundtrip() throws { 57 | let model = TestModelWithEnum.allEnumsPopulated 58 | 59 | let record = try CloudKitRecordEncoder().encode(model) 60 | 61 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestModelWithEnum.self, from: record) 62 | sameModelDecoded.cloudKitSystemFields = nil 63 | 64 | XCTAssertEqual(sameModelDecoded, model) 65 | } 66 | 67 | func testNestedRoundtrip() throws { 68 | let model = TestParent.test 69 | 70 | let record = try CloudKitRecordEncoder().encode(model) 71 | 72 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestParent.self, from: record) 73 | sameModelDecoded.cloudKitSystemFields = nil 74 | 75 | XCTAssertEqual(sameModelDecoded, model) 76 | } 77 | 78 | func testNestedRoundtripOptionalChild() throws { 79 | let model = TestParentOptionalChild.test 80 | 81 | let record = try CloudKitRecordEncoder().encode(model) 82 | 83 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestParentOptionalChild.self, from: record) 84 | sameModelDecoded.cloudKitSystemFields = nil 85 | 86 | XCTAssertEqual(sameModelDecoded, model) 87 | } 88 | 89 | func testNestedRoundtripOptionalChildNil() throws { 90 | let model = TestParentOptionalChild.testNilChild 91 | 92 | let record = try CloudKitRecordEncoder().encode(model) 93 | 94 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestParentOptionalChild.self, from: record) 95 | sameModelDecoded.cloudKitSystemFields = nil 96 | 97 | XCTAssertEqual(sameModelDecoded, model) 98 | } 99 | 100 | func testNestedRoundtripCollection() throws { 101 | let model = TestParentCollection.test 102 | 103 | let record = try CloudKitRecordEncoder().encode(model) 104 | 105 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestParentCollection.self, from: record) 106 | sameModelDecoded.cloudKitSystemFields = nil 107 | 108 | XCTAssertEqual(sameModelDecoded, model) 109 | } 110 | 111 | func testCustomAssetRoundtrip() throws { 112 | let model = TestModelCustomAsset.test 113 | 114 | let record = try CloudKitRecordEncoder().encode(model) 115 | 116 | var sameModelDecoded = try CloudKitRecordDecoder().decode(TestModelCustomAsset.self, from: record) 117 | sameModelDecoded.cloudKitSystemFields = nil 118 | 119 | XCTAssertEqual(sameModelDecoded, model) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/CloudKitRecordEncoderTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitRecordEncoderTests.swift 3 | // CloudKitRecordEncoderTests 4 | // 5 | // Created by Guilherme Rambo on 11/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | @testable import CloudKitCodable 12 | 13 | final class CloudKitRecordEncoderTests: XCTestCase { 14 | 15 | func testComplexPersonStructEncoding() throws { 16 | let record = try CloudKitRecordEncoder().encode(Person.rambo) 17 | 18 | try _validateRamboFields(in: record) 19 | } 20 | 21 | func testCustomZoneIDEncoding() throws { 22 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: CKCurrentUserDefaultName) 23 | 24 | let record = try CloudKitRecordEncoder(zoneID: zoneID).encode(Person.rambo) 25 | try _validateRamboFields(in: record) 26 | 27 | XCTAssert(record.recordID.zoneID == zoneID) 28 | } 29 | 30 | func testSystemFieldsEncoding() throws { 31 | var previouslySavedRambo = Person.rambo 32 | 33 | previouslySavedRambo.cloudKitSystemFields = CKRecord.systemFieldsDataForTesting 34 | 35 | let record = try CloudKitRecordEncoder().encode(previouslySavedRambo) 36 | 37 | XCTAssertEqual(record.recordID.recordName, "RecordABCD") 38 | XCTAssertEqual(record.recordID.zoneID.zoneName, "ZoneABCD") 39 | XCTAssertEqual(record.recordID.zoneID.ownerName, "OwnerABCD") 40 | 41 | try _validateRamboFields(in: record) 42 | } 43 | 44 | func testCustomRecordIdentifierEncoding() throws { 45 | let zoneID = CKRecordZone.ID(zoneName: "ABCDE", ownerName: CKCurrentUserDefaultName) 46 | 47 | let record = try CloudKitRecordEncoder(zoneID: zoneID).encode(PersonWithCustomIdentifier.rambo) 48 | 49 | XCTAssert(record.recordID.zoneID == zoneID) 50 | XCTAssert(record.recordID.recordName == "MY-ID") 51 | } 52 | 53 | func testEnumEncoding() throws { 54 | let model = TestModelWithEnum.allEnumsPopulated 55 | 56 | let record = try CloudKitRecordEncoder().encode(model) 57 | 58 | XCTAssertEqual(record["enumProperty"], "enumCase3") 59 | XCTAssertEqual(record["optionalEnumProperty"], "enumCase2") 60 | XCTAssertEqual(record["intEnumProperty"], 1) 61 | XCTAssertEqual(record["optionalIntEnumProperty"], 2) 62 | } 63 | 64 | func testEnumEncodingNilValue() throws { 65 | let model = TestModelWithEnum.optionalEnumNil 66 | 67 | let record = try CloudKitRecordEncoder().encode(model) 68 | 69 | XCTAssertEqual(record["enumProperty"], "enumCase3") 70 | XCTAssertNil(record["optionalEnumProperty"]) 71 | XCTAssertEqual(record["intEnumProperty"], 1) 72 | XCTAssertNil(record["optionalIntEnumProperty"]) 73 | } 74 | 75 | func testNestedEncoding() throws { 76 | let model = TestParent.test 77 | 78 | let record = try CloudKitRecordEncoder().encode(model) 79 | 80 | let encodedChild = """ 81 | {"name":"Hello Child Name","value":"Hello Child Value"} 82 | """.UTF8Data() 83 | 84 | XCTAssertEqual(record["parentName"], "Hello Parent") 85 | XCTAssertEqual(record["child"], encodedChild) 86 | } 87 | 88 | func testNestedEncodingOptional() throws { 89 | let model = TestParentOptionalChild.test 90 | 91 | let record = try CloudKitRecordEncoder().encode(model) 92 | 93 | let encodedChild = """ 94 | {"name":"Hello Optional Child Name","value":"Hello Optional Child Value"} 95 | """.UTF8Data() 96 | 97 | XCTAssertEqual(record["parentName"], "Hello Parent") 98 | XCTAssertEqual(record["child"], encodedChild) 99 | } 100 | 101 | func testNestedEncodingOptionalNil() throws { 102 | let model = TestParentOptionalChild.testNilChild 103 | 104 | let record = try CloudKitRecordEncoder().encode(model) 105 | 106 | XCTAssertEqual(record["parentName"], "Hello Parent") 107 | XCTAssertNil(record["child"]) 108 | } 109 | 110 | func testNestedEncodingCollection() throws { 111 | let model = TestParentCollection.test 112 | 113 | let record = try CloudKitRecordEncoder().encode(model) 114 | 115 | let encodedChildren = """ 116 | [{"name":"0 - Hello Child Name","value":"0 - Hello Child Value"},{"name":"1 - Hello Child Name","value":"1 - Hello Child Value"},{"name":"2 - Hello Child Name","value":"2 - Hello Child Value"}] 117 | """.UTF8Data() 118 | 119 | XCTAssertEqual(record["parentName"], "Hello Parent Collection") 120 | XCTAssertEqual(record["children"], encodedChildren) 121 | } 122 | 123 | func testCustomAssetEncoding() throws { 124 | let model = TestModelCustomAsset.test 125 | 126 | let record = try CloudKitRecordEncoder().encode(model) 127 | 128 | XCTAssertEqual(record["title"], model.title) 129 | guard let asset = record["contents"] as? CKAsset else { 130 | XCTFail("Expected CloudKitAssetValue to be encoded as CKAsset") 131 | return 132 | } 133 | 134 | let url = asset.fileURL! 135 | 136 | XCTAssertEqual(url.lastPathComponent, "Contents-MyID.json") 137 | 138 | let encodedAsset = """ 139 | {"contentProperty1":"Prop1","contentProperty2":"Prop2","contentProperty3":"Prop3","contentProperty4":"Prop4","id":"MyID"} 140 | """.UTF8Data() 141 | 142 | let assetData = try Data(contentsOf: url) 143 | 144 | XCTAssertEqual(assetData, encodedAsset) 145 | } 146 | 147 | } 148 | 149 | extension String { 150 | func UTF8Data() -> Data { Data(utf8) } 151 | } 152 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/Fixtures/Rambo.ckrecord: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/insidegui/CloudKitCodable/ab4ceab8ed6c1c593234d99f94d9d192219acc22/Tests/CloudKitCodableTests/Fixtures/Rambo.ckrecord -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/TestTypes/Person.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Person.swift 3 | // CloudKitCodableTests 4 | // 5 | // Created by Guilherme Rambo on 11/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import CloudKitCodable 11 | 12 | struct Person: CustomCloudKitCodable, Equatable { 13 | var cloudKitSystemFields: Data? 14 | let name: String 15 | let age: Int 16 | let website: URL 17 | let avatar: URL 18 | let isDeveloper: Bool 19 | 20 | static func ==(lhs: Person, rhs: Person) -> Bool { 21 | return 22 | lhs.name == rhs.name 23 | && lhs.age == rhs.age 24 | && lhs.website == rhs.website 25 | && lhs.avatar == rhs.avatar 26 | && lhs.isDeveloper == rhs.isDeveloper 27 | } 28 | } 29 | 30 | struct PersonWithCustomIdentifier: CustomCloudKitCodable { 31 | var cloudKitSystemFields: Data? 32 | var cloudKitIdentifier: String 33 | let name: String 34 | } 35 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/TestTypes/TestModelCustomAsset.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CloudKitCodable 3 | 4 | struct TestModelCustomAsset: Hashable, CustomCloudKitCodable { 5 | struct Contents: Identifiable, Hashable, CloudKitAssetValue { 6 | var id: String 7 | var contentProperty1: String 8 | var contentProperty2: String 9 | var contentProperty3: String 10 | var contentProperty4: String 11 | } 12 | var cloudKitSystemFields: Data? 13 | var title: String 14 | var contents: Contents 15 | } 16 | 17 | extension TestModelCustomAsset { 18 | static let test = TestModelCustomAsset( 19 | title: "Hello Title", 20 | contents: .init( 21 | id: "MyID", 22 | contentProperty1: "Prop1", 23 | contentProperty2: "Prop2", 24 | contentProperty3: "Prop3", 25 | contentProperty4: "Prop4" 26 | ) 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/TestTypes/TestModelWithEnum.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CloudKitCodable 3 | 4 | struct TestModelWithEnum: CustomCloudKitCodable, Hashable { 5 | enum MyStringEnum: String, CloudKitStringEnum, CaseIterable { 6 | case enumCase0 7 | case enumCase1 8 | case enumCase2 9 | case enumCase3 10 | } 11 | enum MyIntEnum: Int, CloudKitIntEnum, CaseIterable { 12 | case enumCase0 13 | case enumCase1 14 | case enumCase2 15 | case enumCase3 16 | } 17 | var cloudKitSystemFields: Data? 18 | var enumProperty: MyStringEnum 19 | var optionalEnumProperty: MyStringEnum? 20 | var intEnumProperty: MyIntEnum 21 | var optionalIntEnumProperty: MyIntEnum? 22 | } 23 | 24 | extension TestModelWithEnum { 25 | static let allEnumsPopulated = TestModelWithEnum( 26 | enumProperty: .enumCase3, 27 | optionalEnumProperty: .enumCase2, 28 | intEnumProperty: .enumCase1, 29 | optionalIntEnumProperty: .enumCase2 30 | ) 31 | static let optionalEnumNil = TestModelWithEnum( 32 | enumProperty: .enumCase3, 33 | optionalEnumProperty: nil, 34 | intEnumProperty: .enumCase1, 35 | optionalIntEnumProperty: nil 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/TestTypes/TestNestedModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CloudKitCodable 3 | 4 | struct TestParent: CustomCloudKitCodable, Hashable { 5 | struct TestChild: Codable, Hashable { 6 | var name: String 7 | var value: String 8 | } 9 | var cloudKitSystemFields: Data? 10 | var parentName: String 11 | var child: TestChild 12 | var dataProperty: Data 13 | } 14 | 15 | struct TestParentOptionalChild: CustomCloudKitCodable, Hashable { 16 | struct TestOptionalChild: Codable, Hashable { 17 | var name: String 18 | var value: String 19 | } 20 | var cloudKitSystemFields: Data? 21 | var parentName: String 22 | var child: TestOptionalChild? 23 | var dataProperty: Data 24 | } 25 | 26 | struct TestParentCollection: CustomCloudKitCodable, Hashable { 27 | struct TestCollectionChild: Codable, Hashable { 28 | var name: String 29 | var value: String 30 | } 31 | var cloudKitSystemFields: Data? 32 | var parentName: String 33 | var children: [TestCollectionChild] 34 | /// This data property is used to ensure that the special handling of `Data` for JSON-encoded children 35 | /// does not break encoding/decoding of regular data fields. 36 | var dataProperty: Data 37 | } 38 | 39 | extension TestParent { 40 | static let test = TestParent( 41 | parentName: "Hello Parent", 42 | child: .init( 43 | name: "Hello Child Name", 44 | value: "Hello Child Value" 45 | ), 46 | dataProperty: Data([0xFF]) 47 | ) 48 | } 49 | 50 | extension TestParentOptionalChild { 51 | static let test = TestParentOptionalChild( 52 | parentName: "Hello Parent", 53 | child: .init( 54 | name: "Hello Optional Child Name", 55 | value: "Hello Optional Child Value" 56 | ), 57 | dataProperty: Data([0xFF]) 58 | ) 59 | static let testNilChild = TestParentOptionalChild( 60 | parentName: "Hello Parent", 61 | child: nil, 62 | dataProperty: Data([0xFF]) 63 | ) 64 | } 65 | 66 | extension TestParentCollection { 67 | static let test = TestParentCollection( 68 | parentName: "Hello Parent Collection", 69 | children: [ 70 | .init( 71 | name: "0 - Hello Child Name", 72 | value: "0 - Hello Child Value" 73 | ), 74 | .init( 75 | name: "1 - Hello Child Name", 76 | value: "1 - Hello Child Value" 77 | ), 78 | .init( 79 | name: "2 - Hello Child Name", 80 | value: "2 - Hello Child Value" 81 | ), 82 | ], 83 | dataProperty: Data([0xFF]) 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /Tests/CloudKitCodableTests/TestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TestUtils.swift 3 | // CloudKitCodableTests 4 | // 5 | // Created by Guilherme Rambo on 12/05/18. 6 | // Copyright © 2018 Guilherme Rambo. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | import CloudKit 11 | @testable import CloudKitCodable 12 | 13 | extension CKRecord { 14 | /// Creates a temporary record to simulate what would happen when encoding a CKRecord 15 | /// from a value that was previosly encoded to a CKRecord and had its system fields set 16 | static var systemFieldsDataForTesting: Data { 17 | let zoneID = CKRecordZone.ID(zoneName: "ZoneABCD", ownerName: "OwnerABCD") 18 | let recordID = CKRecord.ID(recordName: "RecordABCD", zoneID: zoneID) 19 | let testRecord = CKRecord(recordType: "Person", recordID: recordID) 20 | let coder = NSKeyedArchiver(requiringSecureCoding: true) 21 | testRecord.encodeSystemFields(with: coder) 22 | coder.finishEncoding() 23 | 24 | return coder.encodedData 25 | } 26 | 27 | static var testRecord: CKRecord { 28 | get throws { 29 | guard let url = Bundle.module.url(forResource: "Rambo", withExtension: "ckrecord") else { 30 | fatalError("Required test asset Rambo.ckrecord not found") 31 | } 32 | 33 | let data = try Data(contentsOf: url) 34 | let record = try NSKeyedUnarchiver.unarchivedObject(ofClass: CKRecord.self, from: data) 35 | 36 | return try XCTUnwrap(record) 37 | } 38 | } 39 | } 40 | 41 | /// Validates that all fields in `record` match the expectations of encoding the test `Person` struct to a `CKRecord` 42 | /// 43 | /// - Parameter record: A record generated by encoding `Person.rambo` with `CloudKitRecordEncoder` 44 | func _validateRamboFields(in record: CKRecord) throws { 45 | XCTAssertEqual(record.recordType, "Person") 46 | XCTAssertEqual(record["name"] as? String, "Guilherme Rambo") 47 | XCTAssertEqual(record["age"] as? Int, 26) 48 | XCTAssertEqual(record["website"] as? String, "https://guilhermerambo.me") 49 | XCTAssertEqual(record["isDeveloper"] as? Bool, true) 50 | 51 | guard let asset = record["avatar"] as? CKAsset else { 52 | XCTFail("URL property with a file URL should encode to a CKAsset") 53 | return 54 | } 55 | 56 | let filePath = try XCTUnwrap(asset.fileURL?.path) 57 | XCTAssertEqual(filePath, "/Users/inside/Library/Containers/br.com.guilhermerambo.CloudKitRoundTrip/Data/Library/Caches/CloudKit/aa007d03cf247aebef55372fa57c05d0dc3d8682/Assets/7644AD10-A5A5-4191-B4FF-EF412CC08A52.01ec4e7f3a4fe140bcc758ae2c4a30c7bbb04de8db") 58 | 59 | XCTAssertNil(record[_CKSystemFieldsKeyName], "\(_CKSystemFieldsKeyName) should NOT be encoded to the record directly") 60 | } 61 | 62 | extension Person { 63 | 64 | /// Sample person for tests 65 | static let rambo = Person( 66 | cloudKitSystemFields: nil, 67 | name: "Guilherme Rambo", 68 | age: 26, 69 | website: URL(string:"https://guilhermerambo.me")!, 70 | avatar: URL(fileURLWithPath: "/Users/inside/Library/Containers/br.com.guilhermerambo.CloudKitRoundTrip/Data/Library/Caches/CloudKit/aa007d03cf247aebef55372fa57c05d0dc3d8682/Assets/7644AD10-A5A5-4191-B4FF-EF412CC08A52.01ec4e7f3a4fe140bcc758ae2c4a30c7bbb04de8db"), 71 | isDeveloper: true 72 | ) 73 | 74 | } 75 | 76 | extension PersonWithCustomIdentifier { 77 | 78 | static let rambo = PersonWithCustomIdentifier(cloudKitSystemFields: nil, 79 | cloudKitIdentifier: "MY-ID", 80 | name: "Guilherme Rambo") 81 | } 82 | --------------------------------------------------------------------------------