Skip to content

Commit 1badf6d

Browse files
leogdionclaude
andcommitted
Add lookupZones, fetchRecordChanges, and uploadAssets operations (#44, #46, #30)
Implement three CloudKit Web Services operations to improve API coverage from 35% to 53% (9/17 operations): - lookupZones(): Batch zone lookup by ID with validation - fetchRecordChanges(): Incremental sync with pagination support (manual and automatic) - uploadAssets(): Binary asset uploads with multipart/form-data encoding New public types: - ZoneID: Zone identifier (zoneName, ownerName) - RecordChangesResult: Change result with syncToken and moreComing flag - AssetUploadToken/AssetUploadResult: Upload tokens for record association MistDemo integration tests: - Add --test-lookup-zones flag for zone lookup demonstrations - Add --test-fetch-changes flag with --fetch-all and --sync-token support - Add --test-upload-asset flag with optional --create-record association Created GitHub issues for future enhancements: - #200: AsyncSequence wrapper for fetchRecordChanges streaming - #201: Batch asset upload support (multiple files) - #202: Upload progress tracking for large files - #203: Automatic token retry logic for expired tokens All operations follow MistKit patterns: async/await, typed errors, proper logging, and comprehensive error handling. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent df7836e commit 1badf6d

11 files changed

Lines changed: 1103 additions & 3 deletions

Examples/MistDemo/Sources/MistDemo/MistDemo.swift

Lines changed: 257 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,31 @@ struct MistDemo: AsyncParsableCommand {
5656

5757
@Option(name: .long, help: "CloudKit environment (development or production)")
5858
var environment: String = "development"
59-
59+
60+
@Flag(name: .long, help: "Test lookupZones operation")
61+
var testLookupZones: Bool = false
62+
63+
@Option(name: .long, help: "Comma-separated zone names to lookup")
64+
var zoneNames: String?
65+
66+
@Flag(name: .long, help: "Test fetchRecordChanges operation")
67+
var testFetchChanges: Bool = false
68+
69+
@Option(name: .long, help: "Sync token from previous fetch")
70+
var syncToken: String?
71+
72+
@Flag(name: .long, help: "Fetch all changes automatically (pagination)")
73+
var fetchAll: Bool = false
74+
75+
@Flag(name: .long, help: "Test uploadAssets operation")
76+
var testUploadAsset: Bool = false
77+
78+
@Option(name: .long, help: "Path to file to upload")
79+
var file: String?
80+
81+
@Option(name: .long, help: "Create record of this type with uploaded asset")
82+
var createRecord: String?
83+
6084
func run() async throws {
6185
// Get API token from environment variable if not provided
6286
let resolvedApiToken = apiToken.isEmpty ?
@@ -78,7 +102,13 @@ struct MistDemo: AsyncParsableCommand {
78102
// Use the resolved API token for all operations
79103
let effectiveApiToken = resolvedApiToken
80104

81-
if testAllAuth {
105+
if testLookupZones {
106+
try await demonstrateLookupZones(apiToken: effectiveApiToken)
107+
} else if testFetchChanges {
108+
try await demonstrateFetchChanges(apiToken: effectiveApiToken)
109+
} else if testUploadAsset {
110+
try await demonstrateUploadAsset(apiToken: effectiveApiToken)
111+
} else if testAllAuth {
82112
try await testAllAuthenticationMethods(apiToken: effectiveApiToken)
83113
} else if testApiOnly {
84114
try await testAPIOnlyAuthentication(apiToken: effectiveApiToken)
@@ -642,4 +672,229 @@ struct MistDemo: AsyncParsableCommand {
642672
print(" --private-key-file 'path/to/private_key.pem'")
643673
}
644674
}
675+
676+
// MARK: - New Operation Demonstrations
677+
678+
private func demonstrateLookupZones(apiToken: String) async throws {
679+
print("\n" + String(repeating: "=", count: 60))
680+
print("🔍 Testing lookupZones() Operation")
681+
print(String(repeating: "=", count: 60))
682+
683+
let tokenManager = APITokenManager(apiToken: apiToken)
684+
let service = try CloudKitService(
685+
containerIdentifier: containerIdentifier,
686+
tokenManager: tokenManager,
687+
environment: environment == "production" ? .production : .development,
688+
database: .public
689+
)
690+
691+
let zoneNamesList = self.zoneNames?.split(separator: ",").map(String.init) ?? ["_defaultZone"]
692+
let zoneIDs = zoneNamesList.map { ZoneID(zoneName: $0, ownerName: nil) }
693+
694+
print("\n📋 Looking up \(zoneIDs.count) zone(s):")
695+
for zoneName in zoneNamesList {
696+
print(" - \(zoneName)")
697+
}
698+
699+
do {
700+
let zones = try await service.lookupZones(zoneIDs: zoneIDs)
701+
print("\n✅ Found \(zones.count) zone(s):")
702+
for zone in zones {
703+
print(" - \(zone.zoneName)")
704+
if let owner = zone.ownerRecordName {
705+
print(" Owner: \(owner)")
706+
}
707+
if !zone.capabilities.isEmpty {
708+
print(" Capabilities: \(zone.capabilities.joined(separator: ", "))")
709+
}
710+
}
711+
} catch {
712+
print("\n❌ Error: \(error)")
713+
}
714+
715+
print("\n" + String(repeating: "=", count: 60))
716+
print("✅ lookupZones test completed!")
717+
print(String(repeating: "=", count: 60))
718+
}
719+
720+
private func demonstrateFetchChanges(apiToken: String) async throws {
721+
print("\n" + String(repeating: "=", count: 60))
722+
print("🔄 Testing fetchRecordChanges() Operation")
723+
print(String(repeating: "=", count: 60))
724+
725+
let tokenManager = APITokenManager(apiToken: apiToken)
726+
let service = try CloudKitService(
727+
containerIdentifier: containerIdentifier,
728+
tokenManager: tokenManager,
729+
environment: environment == "production" ? .production : .development,
730+
database: .public
731+
)
732+
733+
do {
734+
if fetchAll {
735+
print("\n📦 Fetching all changes (automatic pagination)...")
736+
if let token = syncToken {
737+
print(" Using sync token: \(token.prefix(20))...")
738+
} else {
739+
print(" Performing initial fetch (no sync token)")
740+
}
741+
742+
let (records, newToken) = try await service.fetchAllRecordChanges(
743+
syncToken: syncToken
744+
)
745+
print("\n✅ Fetched \(records.count) record(s)")
746+
displayRecords(records, limit: 5)
747+
if let token = newToken {
748+
print("\n💾 New sync token: \(token.prefix(20))...")
749+
print(" Save this token to fetch only new changes next time:")
750+
print(" mistdemo --test-fetch-changes --sync-token '\(token)'")
751+
}
752+
} else {
753+
print("\n📄 Fetching single page...")
754+
if let token = syncToken {
755+
print(" Using sync token: \(token.prefix(20))...")
756+
} else {
757+
print(" Performing initial fetch (no sync token)")
758+
}
759+
760+
let result = try await service.fetchRecordChanges(
761+
syncToken: syncToken,
762+
resultsLimit: 10
763+
)
764+
print("\n✅ Fetched \(result.records.count) record(s)")
765+
displayRecords(result.records, limit: 5)
766+
767+
if result.moreComing {
768+
print("\n⚠️ More changes available! Use --sync-token with:")
769+
if let token = result.syncToken {
770+
print(" mistdemo --test-fetch-changes --sync-token '\(token)'")
771+
}
772+
}
773+
774+
if let token = result.syncToken {
775+
print("\n💾 Sync token: \(token.prefix(20))...")
776+
}
777+
}
778+
} catch {
779+
print("\n❌ Error: \(error)")
780+
}
781+
782+
print("\n" + String(repeating: "=", count: 60))
783+
print("✅ fetchRecordChanges test completed!")
784+
print(String(repeating: "=", count: 60))
785+
}
786+
787+
private func displayRecords(_ records: [RecordInfo], limit: Int) {
788+
let displayed = records.prefix(limit)
789+
for record in displayed {
790+
print(" 📝 \(record.recordType) - \(record.recordName)")
791+
if !record.fields.isEmpty {
792+
print(" Fields: \(record.fields.keys.joined(separator: ", "))")
793+
}
794+
}
795+
if records.count > limit {
796+
print(" ... and \(records.count - limit) more")
797+
}
798+
}
799+
800+
private func demonstrateUploadAsset(apiToken: String) async throws {
801+
print("\n" + String(repeating: "=", count: 60))
802+
print("📤 Testing uploadAssets() Operation")
803+
print(String(repeating: "=", count: 60))
804+
805+
guard let filePath = file else {
806+
print("\n❌ Error: --file required")
807+
print(" Usage: mistdemo --test-upload-asset --file path/to/file.png")
808+
return
809+
}
810+
811+
let fileURL = URL(fileURLWithPath: filePath)
812+
813+
guard FileManager.default.fileExists(atPath: filePath) else {
814+
print("\n❌ Error: File not found at path: \(filePath)")
815+
return
816+
}
817+
818+
let tokenManager = APITokenManager(apiToken: apiToken)
819+
let service = try CloudKitService(
820+
containerIdentifier: containerIdentifier,
821+
tokenManager: tokenManager,
822+
environment: environment == "production" ? .production : .development,
823+
database: .public
824+
)
825+
826+
do {
827+
let data = try Data(contentsOf: fileURL)
828+
let sizeInMB = Double(data.count) / 1024 / 1024
829+
print("\n📁 File: \(fileURL.lastPathComponent) (\(String(format: "%.2f", sizeInMB)) MB)")
830+
831+
print("⬆️ Uploading...")
832+
let result = try await service.uploadAssets(data: data)
833+
834+
print("\n✅ Upload successful!")
835+
print("🎫 Received \(result.tokens.count) token(s):")
836+
for (index, token) in result.tokens.enumerated() {
837+
print(" Token \(index + 1):")
838+
if let url = token.url {
839+
print(" URL: \(url.prefix(50))...")
840+
}
841+
if let recordName = token.recordName {
842+
print(" Record: \(recordName)")
843+
}
844+
if let fieldName = token.fieldName {
845+
print(" Field: \(fieldName)")
846+
}
847+
}
848+
849+
// Optional: Create record with asset
850+
if let recordType = createRecord, let token = result.tokens.first {
851+
print("\n📝 Creating \(recordType) record with asset...")
852+
try await createRecordWithAsset(
853+
service: service,
854+
recordType: recordType,
855+
filename: fileURL.lastPathComponent,
856+
token: token,
857+
fileSize: data.count
858+
)
859+
}
860+
861+
} catch let error as CloudKitError {
862+
print("\n❌ CloudKit Error: \(error)")
863+
} catch {
864+
print("\n❌ Error: \(error)")
865+
}
866+
867+
print("\n" + String(repeating: "=", count: 60))
868+
print("✅ uploadAssets test completed!")
869+
print(String(repeating: "=", count: 60))
870+
}
871+
872+
private func createRecordWithAsset(
873+
service: CloudKitService,
874+
recordType: String,
875+
filename: String,
876+
token: AssetUploadToken,
877+
fileSize: Int
878+
) async throws {
879+
let asset = FieldValue.Asset(
880+
fileChecksum: nil,
881+
size: Int64(fileSize),
882+
referenceChecksum: nil,
883+
wrappingKey: nil,
884+
receipt: nil,
885+
downloadURL: token.url
886+
)
887+
888+
let record = try await service.createRecord(
889+
recordType: recordType,
890+
fields: [
891+
"filename": .string(filename),
892+
"file": .asset(asset)
893+
]
894+
)
895+
896+
print(" ✅ Created record: \(record.recordName)")
897+
print(" 📝 Type: \(record.recordType)")
898+
print(" 🆔 Record ID: \(record.recordName)")
899+
}
645900
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
//
2+
// AssetUploadToken.swift
3+
// MistKit
4+
//
5+
// Created by Leo Dion.
6+
// Copyright © 2025 BrightDigit.
7+
//
8+
// Permission is hereby granted, free of charge, to any person
9+
// obtaining a copy of this software and associated documentation
10+
// files (the "Software"), to deal in the Software without
11+
// restriction, including without limitation the rights to use,
12+
// copy, modify, merge, publish, distribute, sublicense, and/or
13+
// sell copies of the Software, and to permit persons to whom the
14+
// Software is furnished to do so, subject to the following
15+
// conditions:
16+
//
17+
// The above copyright notice and this permission notice shall be
18+
// included in all copies or substantial portions of the Software.
19+
//
20+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
21+
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
22+
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
23+
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
24+
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
25+
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
26+
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
27+
// OTHER DEALINGS IN THE SOFTWARE.
28+
//
29+
30+
public import Foundation
31+
32+
/// Token returned after uploading an asset
33+
///
34+
/// After uploading binary data, CloudKit returns tokens that must be
35+
/// associated with record fields using a subsequent modifyRecords operation.
36+
public struct AssetUploadToken: Sendable, Equatable {
37+
/// The upload URL (may be used for download reference)
38+
public let url: String?
39+
/// The record name this token is associated with
40+
public let recordName: String?
41+
/// The field name this token should be assigned to
42+
public let fieldName: String?
43+
44+
/// Initialize an asset upload token
45+
public init(url: String?, recordName: String?, fieldName: String?) {
46+
self.url = url
47+
self.recordName = recordName
48+
self.fieldName = fieldName
49+
}
50+
51+
internal init(from token: Components.Schemas.AssetUploadResponse.tokensPayloadPayload) {
52+
self.url = token.url
53+
self.recordName = token.recordName
54+
self.fieldName = token.fieldName
55+
}
56+
}
57+
58+
/// Result of an asset upload operation
59+
///
60+
/// Contains tokens that can be used to associate uploaded assets with
61+
/// record fields in a subsequent modify operation.
62+
public struct AssetUploadResult: Sendable {
63+
/// Array of upload tokens for the uploaded assets
64+
public let tokens: [AssetUploadToken]
65+
66+
/// Initialize an asset upload result
67+
public init(tokens: [AssetUploadToken]) {
68+
self.tokens = tokens
69+
}
70+
71+
internal init(from response: Components.Schemas.AssetUploadResponse) {
72+
self.tokens = response.tokens?.map { AssetUploadToken(from: $0) } ?? []
73+
}
74+
}

0 commit comments

Comments
 (0)