Skip to content
/ Canopy Public

A library that helps you isolate CloudKit dependency and write testable code using CloudKit.

License

Notifications You must be signed in to change notification settings

tact/Canopy

Repository files navigation

Canopy

Canopy helps you write better, more testable CloudKit apps.

Installing Canopy

Canopy is distributed as a Swift Package Manager package.

If you use Xcode UI to manage your dependencies, add https://github.com/Tact/Canopy as the dependency for your project.

If you use SPM Package.swift, add this:

dependencies: [
  .package(
    url: "https://github.com/Tact/Canopy",
    from: "0.5.0"
  )
]

Using Canopy

One-line example

To fetch a record from CloudKit private database which has the record ID exampleID, use this Canopy call:

let result = await Canopy().databaseAPI(usingDatabaseScope: .private).fetchRecords(with: [CKRecord.ID(recordName: "exampleID")])
switch result {
  case .success(let fetchRecordsResult):
    // handle fetchRecordsResult. Examine its foundRecords and notFoundRecordIDs properties.
  case .failure(let ckRecordError):
    // handle error
}

Using throwing return type

Canopy provides all its API as async Result. Many people prefer to instead use throwing API. It’s easy to convert Canopy API calls to throwing style at the call site with the get() API. For the above example, follow this approach:

do {
  let result = try await Canopy().databaseAPI(usingDatabaseScope: .private).fetchRecords().get()
  // use result
} catch {
  // handle thrown error
}

Dependency injection for testability

Canopy is designed for enabling your code to be testable. You do your part by using dependency injection pattern in most of your code and features. Most of your code should not instantiate Canopy directly, but should receive it from outside. For example:

actor MyService {
  private let canopy: CanopyType
  init(canopy: CanopyType) {
    self.canopy = canopy
  }

  func someFeature() async {
    let databaseAPI = await canopy.getDatabaseAPI(usingDatabaseScope: .private)
    // call databaseAPI functions to
    // retrieve and modify records, zones, subscriptions …
  }
}

In live use of your app, you initiate and inject the live Canopy object that talks to CloudKit. When independently testing your features, you instead inject a mock Canopy object that doesn’t talk to any cloud services, but instead plays back mock responses.

Read more: Testable CloudKit apps with Canopy

Dependency injection with swift-dependency

Canopy implements cloudKit dependency key for swift-dependencies. If you use swift-dependencies, you use Canopy like this:

struct MyFeature {
  @Dependency(\.cloudKit) private var canopy
  func myFeature() async {
    let recordsResult = await canopy.databaseAPI(usingDatabaseScope: .private).fetchRecords()
  }
}

See swift-dependencies documentation for more info about how to use dependencies, and inject desired values for the Canopy dependency for your previews and tests.

Understanding Canopy

The Canopy package has three parts.

Libraries

Libraries provide the main Canopy functionality and value. Canopy is the main library, and CanopyTestTools helps you build tests.

Documentation

The Canopy documentation site at https://canopy-docs.justtact.com has documentation for the libraries, as well as information about the library motivation and some ideas and best practices about using CloudKit. The documentation is generated by DocC from this repository, and can also be used inline in Xcode.

Some highlights from documentation:

Canopy motivation and scope

Testable CloudKit apps with Canopy

iCloud Advanced Data Protection

Example app

The Thoughts example app showcases using Canopy in a real app, and demonstrates some best practices for modern multi-platform, multi-window app development.

Thoughts example app

Authors and credits

Canopy was built, and continues to be built, as part of Tact app.

Major contributors: Jaanus Kase, Andrew Tetlaw, Henry Cooper

Thanks to: Priidu Zilmer, Roger Sheen, Margus Holland