Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [CloudKitCodable]
64 changes: 14 additions & 50 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,15 @@

[![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)

This project implements a `CloudKitRecordEncoder` and a `CloudKitRecordDecoder` so you can easily convert your custom data structure to a `CKRecord` and convert your `CKRecord` back to your custom data structure.

**Be aware this is an initial implementation that's not being used in production (yet) and it doesn't support nesting. Nested values would have to be encoded as `CKReference` and I haven't implemented that yet (feel free to open a PR 🤓).**
This project implements `CloudKitRecordEncoder` and `CloudKitRecordDecoder`, allowing for custom data types to be converted to/from `CKRecord` automatically.

## Usage

### `CustomCloudKitCodable`

The types you want to convert to/from `CKRecord` must implement the `CustomCloudKitCodable` protocol. This is necessary because unlike most implementations of encoders/decoders, we are not converting to/from `Data`, but to/from `CKRecord`, which has some special requirements.

There are also two other protocols: `CustomCloudKitEncodable` and `CustomCloudKitDecodable`. You can use those if you only need either encoding or decoding respectively.

The protocol requires two properties on the type you want to convert to/from `CKRecord`:

```swift
var cloudKitSystemFields: Data? { get }
```

This will be used to store the system fields for the `CKRecord` when decoding. The system fields contain metadata for the record such as its unique identifier and they're very important when syncing.

```swift
var cloudKitRecordType: String { get }
```

This property should return the record type for your custom type. It's implemented automatically to return the name of the type, you only need to implement this if you need to customize the record type.

### URLs
There's special handling for URLs because of the way CloudKit works with files. If you have a property that's a remote `URL` (i.e. a website), it's encoded as a `String` (CloudKit doesn't support URLs natively) and decoded back as a `URL`.

If your property is a `URL` and it contains a `URL` to a local file, it is encoded as a `CKAsset`, the file will be automatically uploaded to CloudKit when you save the containing record and downloaded when you get the record from the cloud. The decoded `URL` will contain the `URL` for the location on disk where CloudKit has downloaded the file.
For details on how to use CloudKitCodable, please check the included documentation.

### Example

Let's say you have a `Person` model you want to sync to CloudKit. This is what the model would look like:
Declaring a model that can be encoded as a `CKRecord`:

```swift
struct Person: CustomCloudKitCodable {
Expand All @@ -48,19 +23,10 @@ struct Person: CustomCloudKitCodable {
}
```

Notice I didn't implement `cloudKitRecordType`, in that case, the `CKRecord` type for this model will be `Person` (the name of the type itself).

Now, before saving the record to CloudKit, we encode it:
Creating a `CKRecord` from a `CustomCloudKitCodable` type:

```swift
let rambo = Person(
cloudKitSystemFields: nil,
name: "Guilherme Rambo",
age: 26,
website: URL(string:"https://guilhermerambo.me")!,
avatar: URL(fileURLWithPath: "/Users/inside/Pictures/avatar.png"),
isDeveloper: true
)
let rambo = Person(...)

do {
let record = try CloudKitRecordEncoder().encode(rambo)
Expand All @@ -70,9 +36,7 @@ do {
}
```

Since `avatar` points to a local file, the corresponding file will be uploaded as a `CKAsset` when the record is saved to CloudKit and downloaded back when the record is retrieved.

To decode the record:
Decoding a `CustomCloudKitCodable` type from a `CKRecord`:

```swift
let record = // record obtained from CloudKit
Expand All @@ -83,23 +47,23 @@ do {
}
```

## Requirements
## Minimum Deployment Targets

- iOS 13.0+
- macOS 11.0+
- Xcode 13.2+
- iOS 14
- tvOS 14
- watchOS 5
- macOS 11
- Xcode 15 (recommended)

## Installation

### Swift Package Manager

[Swift Package Manager](https://www.swift.org/package-manager) is a tool for automating the distribution of Swift code and is integrated into the Swift build system.

Once you have your Swift package set up, adding CloudKitCodable as a dependency is as easy as adding it to the dependencies value of your `Package.swift`.
Add CloudKitCodable to your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/insidegui/CloudKitCodable.git", from: "0.2.0")
.package(url: "https://github.com/insidegui/CloudKitCodable.git", from: "0.3.0")
]
```

Expand Down
35 changes: 30 additions & 5 deletions Sources/CloudKitCodable/CloudKitAssetValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,30 @@ import UniformTypeIdentifiers

/// Adopted by `Codable` types that can be nested in ``CustomCloudKitCodable`` types, represented as `CKAsset` in records.
///
/// The corresponding `CKRecord` field is encoded as a `CKAsset` with a file containing the encoded representation of the value.
/// You implement `CloudKitAssetValue` for `Codable` types that can be used as properties of a type conforming to ``CustomCloudKitCodable``.
///
/// This allows ``CloudKitRecordEncoder`` to encode the nested type as a `CKAsset` containing a (by default) JSON-encoded representation of the value.
/// When decoding with ``CloudKitRecordDecoder``, the local file downloaded from CloudKit is then read and decoded back as the corresponding value.
///
/// Implementers can customize the encoding/decoding, file type, and asset file name, but there are default implementations for all of this protocol's requirements.
/// Implementations can customize the encoding/decoding, file type, and asset file name, but there are default implementations for all of this protocol's requirements.
public protocol CloudKitAssetValue: Codable {

/// The default content type for `CKAsset` files representing values of this type.
///
/// When using the default implementations of ``CloudKitAssetValue/encoded()`` and ``CloudKitAssetValue/decoded(from:type:)``,
/// the preferred content type determines which encoder/decoder is used for the value:
///
/// - `.json`: uses `JSONEncoder` and `JSONDecoder`
/// - `.xmlPropertyList`: uses `PropertyListEncoder` (XML) and `PropertyListDecoder`
/// - `.binaryPropertyList`: uses `PropertyListEncoder` (binary) and `PropertyListDecoder`
///
/// There's a default implementation that returns `.json`, so by default ``CloudKitAssetValue`` types are encoded as JSON.
///
/// - 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
/// based on the asset downloaded from CloudKit, using the declared type as a fallback.
static var preferredContentType: UTType { get }

/// The preferred filename for this value when being encoded as a `CKAsset`.
/// The file name for this value when being encoded as a `CKAsset`.
///
/// There's a default implementation for a filename with the format `<type>-<uuid>.(json/plist)`,
/// and a default implementation for `Identifiable` types that uses the `id` property instead of a random UUID.
Expand All @@ -31,7 +44,8 @@ public protocol CloudKitAssetValue: Codable {
/// - type: Determines the type of decoder to be used.
/// - Returns: The instance of the type.
///
/// There is a default implementation supporting JSON and PLIST types that uses `JSONDecoder`/`PropertyListDecoder`.
/// The default implementation uses `JSONDecoder`/`PropertyListDecoder` depending upon the `type`.
/// For more details, see the documentation for ``preferredContentType-8zbfl``.
static func decoded(from data: Data, type: UTType) throws -> Self
}

Expand All @@ -57,6 +71,12 @@ public extension CloudKitAssetValue where Self: Identifiable {
}

public extension CloudKitAssetValue {

/// Encodes the nested value.
/// - Returns: The encoded data.
///
/// This default implementation uses ``preferredContentType-8zbfl`` in order to determine which encoder to use.
/// For more details, see the documentation for ``preferredContentType-8zbfl``.
func encoded() throws -> Data {
let type = Self.preferredContentType
if type.conforms(to: .json) {
Expand All @@ -71,7 +91,12 @@ public extension CloudKitAssetValue {
throw EncodingError.invalidValue(self, .init(codingPath: [], debugDescription: "Unsupported content type \"\(type.identifier)\": the default implementation only supports JSON and PLIST"))
}
}


/// Decodes the nested value using data fetched from CloudKit.
/// - Parameters:
/// - data: The encoded data fetched from CloudKit.
/// - type: The `UTType` of the data.
/// - Returns: A decoded instance of the type.
static func decoded(from data: Data, type: UTType) throws -> Self {
if type.conforms(to: .json) {
return try JSONDecoder.nestedCloudKitValue.decode(Self.self, from: data)
Expand Down
32 changes: 32 additions & 0 deletions Sources/CloudKitCodable/CloudKitEnum.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Base protocol for `enum` types that can be used as properties of types conforming to ``CloudKitRecordRepresentable``.
public protocol CloudKitEnum {

/// The fallback `enum` `case` to be used when decoding from `CKRecord` encounters an unknown `case`.
///
/// You implement this in custom enums that can be properties of ``CloudKitRecordRepresentable`` types in order to provide
/// a fallback value when ``CloudKitRecordDecoder`` encounters a raw value that's unknown.
///
/// 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,
/// then it's possible for data on CloudKit to contain raw values that can't be decoded by an older version of the app.
///
/// - Tip: if you'd like to have the model decoding fail completely if one of its `enum` properties has an unknown raw value,
/// then just return `nil` from your implementation.
static var cloudKitFallbackCase: Self? { get }
}

public extension CloudKitEnum where Self: CaseIterable {
/// Uses the first `enum` case as the fallback when decoding from `CKRecord` encounters an unknown `case`.
static var cloudKitFallbackCase: Self? { allCases.first }
}

/// Implemented by `enum` types with `String` raw value that can be used as properties of types conforming to ``CloudKitRecordRepresentable``.
///
/// See ``CloudKitEnum`` for more details.
public protocol CloudKitStringEnum: Codable, RawRepresentable, CloudKitEnum where RawValue == String { }

/// Implemented by `enum` types with `Int` raw value that can be used as properties of types conforming to ``CloudKitRecordRepresentable``.
///
/// See ``CloudKitEnum`` for more details.
public protocol CloudKitIntEnum: Codable, RawRepresentable, CloudKitEnum where RawValue == Int { }
16 changes: 16 additions & 0 deletions Sources/CloudKitCodable/CloudKitRecordDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,28 @@
import Foundation
import CloudKit

/// A decoder that takes a `CKRecord` and produces a value conforming to ``CustomCloudKitDecodable``.
///
/// You use an instance of ``CloudKitRecordDecoder`` in order to transform a `CKRecord` downloaded from CloudKit into a value of your custom data type.
final public class CloudKitRecordDecoder {

/// Decodes a value conforming to ``CustomCloudKitDecodable`` from a `CKRecord` fetched from CloudKit.
/// - Parameters:
/// - type: The type of value.
/// - record: The record that was fetched from CloudKit.
/// - Returns: The decoded value with its properties matching those of the `CKRecord`.
///
/// Once decoded from a `CKRecord`, your value will have its ``CloudKitRecordRepresentable/cloudKitSystemFields`` set to the corresponding
/// metadata from the `CKRecord`. When encoding the same value again, such as when updating a record, ``CloudKitRecordEncoder`` will use this encoded metadata
/// to produce a record that CloudKit will recognize as being the same "instance".
public func decode<T>(_ type: T.Type, from record: CKRecord) throws -> T where T : Decodable {
let decoder = _CloudKitRecordDecoder(record: record)
return try T(from: decoder)
}

/// Creates a new instance of the decoder.
///
/// - Tip: You may safely reuse an instance of ``CloudKitRecordDecoder`` for multiple operations.
public init() { }
}

Expand Down
40 changes: 38 additions & 2 deletions Sources/CloudKitCodable/CloudKitRecordEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@
import Foundation
import CloudKit

/// Errors that can occur when encoding a custom type to `CKRecord`.
public enum CloudKitRecordEncodingError: Error {
/// A given model property contains a value that can't be encoded into the `CKRecord`.
///
/// To learn more about supported data types and limitations, see <doc:DataTypes>.
case unsupportedValueForKey(String)
/// The existing value in ``CloudKitRecordRepresentable/cloudKitSystemFields`` couldn't be decoded
/// for constructing an updated version of the corresponding `CKRecord`.
case systemFieldsDecode(String)
@available(*, deprecated, message: "This is no longer thrown and is kept for source compatibility.")
case referencesNotSupported(String)
/// A `Data` property or a nested `Codable` value ended up being too large for encoding into a `CKRecord`.
///
/// - Tip: If you're experiencing this error when encoding a model that has a nested `Codable` property,
/// consider adopting ``CloudKitAssetValue`` so that the property can be encoded as a `CKAsset` instead.
case dataFieldTooLarge(key: String, size: Int)

/// A description of the encoding error.
public var localizedDescription: String {
switch self {
case .unsupportedValueForKey(let key):
Expand All @@ -33,9 +45,28 @@ public enum CloudKitRecordEncodingError: Error {
}
}

/// An encoder that takes a value conforming to ``CustomCloudKitEncodable`` and produces a `CKRecord`.
///
/// You use an instance of ``CloudKitRecordEncoder`` in order to transform your custom data type into a `CKRecord` before uploading it to CloudKit.
public class CloudKitRecordEncoder {
public var zoneID: CKRecordZone.ID?

/// The CloudKit zone identifier that will be associated with the record created by this encoder.
///
/// - Note: This property is ignored when encoding a value with its ``CloudKitRecordRepresentable/cloudKitSystemFields`` property set.
/// When that's the case, the zone ID is read from the record metadata encoded in the system fields.
public var zoneID: CKRecordZone.ID?

/// Encodes a value conforming to ``CustomCloudKitEncodable``, turning it into a `CKRecord`.
/// - Parameter value: The value to be encoded.
/// - Returns: A `CKRecord` representing the value.
///
/// Your custom data type that conforms to ``CustomCloudKitEncodable`` is turned into a `CKRecord` where each record field
/// represents a property of your type, according to its `CodingKeys`.
///
/// If the encoder is initialized with a ``zoneID``, then the ID of the `CKRecord` will include that zone ID.
///
/// When encoding a value that's already been through the CloudKit servers, its ``CloudKitRecordRepresentable/cloudKitSystemFields`` should be available,
/// in which case the encoder will construct a `CKRecord` with the metadata corresponding to the record on the server.
public func encode(_ value: Encodable) throws -> CKRecord {
let type = recordTypeName(for: value)
let name = recordName(for: value)
Expand All @@ -62,7 +93,12 @@ public class CloudKitRecordEncoder {
return UUID().uuidString
}
}


/// Initializes the encoder.
/// - Parameter zoneID: If provided, the `CKRecord` produced will have its record ID
/// set to the specified zone. Uses the default CloudKit zone if the zone is not specified.
///
/// - Tip: You may safely reuse an instance of ``CloudKitRecordEncoder`` for multiple operations.
public init(zoneID: CKRecordZone.ID? = nil) {
self.zoneID = zoneID
}
Expand Down
Loading