From 0d0a8be6be580e5e3965cad2a1b82eb1f54e992f Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 14:35:07 -0400 Subject: [PATCH 1/5] Add records/resolve and records/accept share operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements CloudKit Web Services' two sharing endpoints, both documented only in Apple's archived CloudKit Web Services Reference: - `records/resolve` (#41) — resolves share short GUIDs into information about the shared records: root record, `cloudKit.share` record, owner identity, and the caller's participation. - `records/accept` (#42) — accepts shares on behalf of the current user, returning the same result shape with the caller's resulting participation. Both take `{ shortGUIDs: [ShortGUID] }` and return `{ results: [ShortGUIDResult] }`. Apple's reference fixes the path's database scope to `public`, and both act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose no `database:` parameter. Both validate the request as a whole (a bad short GUID fails the entire call), so there is no per-item RecordResult-style failure variant. Spec changes (openapi.yaml, regenerated via Scripts/generate-openapi.sh): - New paths `records/resolve` + `records/accept`. - New schemas `ShortGUID`, `ShortGUIDResult`, `ShortGUIDResultResponse`, `ShareParticipant`, `ShareReference`, `ShareTargetReference`. - Share request keys on `RecordRequest` (`createShortGUID`, `forRecord`, `publicPermission`, `participants`) and share response keys on `RecordResponse` (`shortGUID`, `share`, `publicPermission`, `participants`, `owner`, `currentUserParticipant`), per the #42 gap analysis. Domain models land in Sources/MistKit/Models/Sharing/. `ShareInfo` lifts the share-specific keys off a `cloudKit.share` record, since `RecordInfo` models a plain record and carries no sharing metadata. `Environment` gains `Codable` so `ShareRecordInfo` can synthesize it. Verified: swift build, swift test (571 tests, 178 suites, all passing), swift-format, and ./Scripts/lint.sh (0 violations, no unused code). Closes #41 Closes #42 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 9 + README.md | 7 +- .../CloudKitResponseProcessor+Sharing.swift | 72 + .../CloudKitService+ShareOperations.swift | 124 ++ Sources/MistKit/Models/Environment.swift | 2 +- .../Sharing/ShareAcceptanceStatus.swift | 63 + .../Models/Sharing/ShareDatabaseScope.swift | 56 + .../MistKit/Models/Sharing/ShareInfo.swift | 92 + .../Models/Sharing/ShareParticipant.swift | 71 + .../Models/Sharing/ShareParticipantType.swift | 67 + .../Models/Sharing/SharePermission.swift | 73 + .../Models/Sharing/SharePotentialMatch.swift | 75 + .../Models/Sharing/ShareRecordInfo.swift | 168 ++ .../MistKit/Models/Sharing/ShortGUID.swift | 84 + .../MistKit/OpenAPI/OperationInputPath.swift | 4 + .../Operations.acceptShares.Output.swift | 52 + .../Operations.resolveShortGUIDs.Output.swift | 52 + Sources/MistKitOpenAPI/Client.swift | 658 ++++++ Sources/MistKitOpenAPI/Types.swift | 1828 ++++++++++++++++- .../CloudKitServiceTests.Sharing+Accept.swift | 112 + ...CloudKitServiceTests.Sharing+Helpers.swift | 158 ++ ...CloudKitServiceTests.Sharing+Resolve.swift | 199 ++ ...oudKitServiceTests.Sharing+ShareInfo.swift | 95 + .../Models/Sharing/ShareModelTests.swift | 160 ++ openapi.yaml | 316 +++ 25 files changed, 4592 insertions(+), 5 deletions(-) create mode 100644 Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Sharing.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareDatabaseScope.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareInfo.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareParticipant.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareParticipantType.swift create mode 100644 Sources/MistKit/Models/Sharing/SharePermission.swift create mode 100644 Sources/MistKit/Models/Sharing/SharePotentialMatch.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareRecordInfo.swift create mode 100644 Sources/MistKit/Models/Sharing/ShortGUID.swift create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.acceptShares.Output.swift create mode 100644 Sources/MistKit/OpenAPI/Operations/Operations.resolveShortGUIDs.Output.swift create mode 100644 Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift create mode 100644 Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift create mode 100644 Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift create mode 100644 Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift create mode 100644 Tests/MistKitTests/Models/Sharing/ShareModelTests.swift diff --git a/AGENTS.md b/AGENTS.md index 9c55f839..4d69e8ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -196,6 +196,7 @@ MistKit/ | `CloudKitService+LookupAllRecords.swift` | `lookupAllRecords(recordNames:desiredKeys:database:batchSize:)` — auto-chunking convenience over `lookupRecords` | | `CloudKitService+UserIdentityChunking.swift` | `discoverAllUserIdentities(lookupInfos:batchSize:)` — auto-chunking convenience over `discoverUserIdentities` | | `CloudKitService+BatchChunking.swift` | internal `chunkedBatches` helper backing the auto-chunking conveniences | +| `CloudKitService+ShareOperations.swift` | `resolveShares(_:)`, `acceptShares(_:)` *(public DB + web-auth, fixed — no `database:` parameter)* | | `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | | `CloudKitService+AssetUpload.swift` | `uploadAssetData` | | `CloudKitService+RecordManaging.swift` | record-managing convenience surface | @@ -215,6 +216,14 @@ MistKit/ - `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. - `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. +**Share Operations (issues #41 / #42 — public DB + web-auth required):** +- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUID]` into `[ShareRecordInfo]` (root record, `cloudKit.share` record, owner identity, the caller's participation). +- `acceptShares(_:)` → POST `/records/accept` — accepts `[ShortGUID]` on behalf of the current user; returns the same `[ShareRecordInfo]` shape reporting the caller's resulting participation. + +Both endpoints are documented **only** in Apple's archived CloudKit Web Services Reference (`FetchingRecordInformation` / `AcceptingShareRecords`), which fixes the path's database scope to `public`; they act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose **no** `database:` parameter. Both validate the request as a whole: a bad short GUID fails the entire call rather than producing a per-item failure, so there is no `RecordResult`-style failure variant. + +Set `ShortGUID.shouldFetchRootRecord` to have CloudKit include the shared root record, optionally narrowed by `rootRecordDesiredKeys`. When CloudKit cannot match the caller to exactly one invited participant, `ShareRecordInfo.potentialMatchList` is non-empty and the user must choose which invitation they are claiming. Domain models live in `Sources/MistKit/Models/Sharing/`. + **Batch chunking (issue #307):** the two non-deprecated operations capped at CloudKit's 200-item-per-request limit (`CloudKitService.maxRecordsPerRequest`) each pair a single-request primitive with an auto-chunking convenience that splits the input into ≤`batchSize` batches, calls the primitive per batch, and concatenates results in input order. This mirrors the `queryRecords`/`queryAllRecords` page-primitive + auto-paginating-extension pattern. Because chunk count is `ceil(input.count / batchSize)` — deterministic and finite — there is **no** `maxPages`-style throwing ceiling; `batchSize` (default `maxRecordsPerRequest`, clamped to `1...maxRecordsPerRequest`) is the only knob. The shared engine is `chunkedBatches` (`CloudKitService+BatchChunking.swift`). | Primitive (single request) | Auto-chunking convenience | diff --git a/README.md b/README.md index 12fdd2c4..078f4f8f 100644 --- a/README.md +++ b/README.md @@ -493,12 +493,15 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Users by Record Name (users/lookup/id)](https://github.com/brightdigit/MistKit/issues/35) ✅ *(Apple-deprecated — prefer `discoverAllUserIdentities`)* - [x] Auto-chunking conveniences for batch operations ([#389](https://github.com/brightdigit/MistKit/pull/389)) ✅ +### v1.0.0-beta.4 + +- [x] [Fetching Record Information (records/resolve)](https://github.com/brightdigit/MistKit/issues/41) ✅ +- [x] [Accepting Share Records (records/accept)](https://github.com/brightdigit/MistKit/issues/42) ✅ + ### Backlog / Post-beta - [ ] [Discovering All User Identities (GET users/discover)](https://github.com/brightdigit/MistKit/issues/28) - [ ] [Fetching Contacts (users/lookup/contacts)](https://github.com/brightdigit/MistKit/issues/33) -- [ ] [Fetching Record Information (records/resolve)](https://github.com/brightdigit/MistKit/issues/41) -- [ ] [Accepting Share Records (records/accept)](https://github.com/brightdigit/MistKit/issues/42) - [ ] [Fetching Database Changes (changes/database)](https://github.com/brightdigit/MistKit/issues/46) - [ ] [Fetching Record Zone Changes (changes/zone)](https://github.com/brightdigit/MistKit/issues/47) - [ ] [Feature: Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) diff --git a/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Sharing.swift b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Sharing.swift new file mode 100644 index 00000000..edaaff3c --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitResponseProcessor+Sharing.swift @@ -0,0 +1,72 @@ +// +// CloudKitResponseProcessor+Sharing.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension CloudKitResponseProcessor { + /// Process resolveShortGUIDs response + /// - Parameter response: The response to process + /// - Returns: The extracted short-GUID results + /// - Throws: CloudKitError for various error conditions + internal func processResolveShortGUIDsResponse( + _ response: Operations.resolveShortGUIDs.Output + ) async throws(CloudKitError) -> Components.Schemas.ShortGUIDResultResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let resolveData): + return resolveData + } + case .badRequest, .unauthorized, .forbidden, .notFound, .conflict, + .preconditionFailed, .contentTooLarge, .misdirectedRequest, + .tooManyRequests, .internalServerError, .serviceUnavailable, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } + + /// Process acceptShares response + /// - Parameter response: The response to process + /// - Returns: The extracted short-GUID results + /// - Throws: CloudKitError for various error conditions + internal func processAcceptSharesResponse( + _ response: Operations.acceptShares.Output + ) async throws(CloudKitError) -> Components.Schemas.ShortGUIDResultResponse { + switch response { + case .ok(let okResponse): + switch okResponse.body { + case .json(let acceptData): + return acceptData + } + case .badRequest, .unauthorized, .forbidden, .notFound, .conflict, + .preconditionFailed, .contentTooLarge, .misdirectedRequest, + .tooManyRequests, .internalServerError, .serviceUnavailable, .undocumented: + throw CloudKitError(response) ?? .invalidResponse + } + } +} diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift new file mode 100644 index 00000000..bd0020cb --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift @@ -0,0 +1,124 @@ +// +// CloudKitService+ShareOperations.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension CloudKitService { + /// Resolve share short GUIDs into information about the shared records + /// (`records/resolve`). + /// + /// Given the short GUIDs carried by share URLs, returns the root record, + /// the governing `cloudKit.share` record, the owner's identity, and the + /// caller's participation in each share. Set + /// ``ShortGUID/shouldFetchRootRecord`` to have CloudKit include the root + /// record itself, optionally narrowed by + /// ``ShortGUID/rootRecordDesiredKeys``. + /// + /// Routed against the public database with web-auth credentials — Apple's + /// reference fixes the path's database scope to `public`, and resolution is + /// performed on behalf of the *current* user, so the database is not exposed + /// to callers. The service's `Credentials` must include an `apiAuth` with a + /// `webAuthToken`. + /// + /// - Parameter shortGUIDs: The short GUIDs identifying the shares to resolve. + /// - Returns: One ``ShareRecordInfo`` per requested short GUID, in request + /// order. + /// - Throws: ``CloudKitError``. The endpoint validates the whole request, so + /// a bad short GUID fails the entire call rather than producing a per-item + /// failure. + public func resolveShares( + _ shortGUIDs: [ShortGUID] + ) async throws(CloudKitError) -> [ShareRecordInfo] { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.resolveShortGUIDs( + .init( + path: Operations.resolveShortGUIDs.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ), + body: .json( + .init(shortGUIDs: shortGUIDs.map(Components.Schemas.ShortGUID.init(from:))) + ) + ) + ) + + let resolveData: Components.Schemas.ShortGUIDResultResponse = + try await responseProcessor.processResolveShortGUIDsResponse(response) + return try (resolveData.results ?? []).map(ShareRecordInfo.init(from:)) + } catch { + throw mapToCloudKitError(error, context: "resolveShares") + } + } + + /// Accept shares on behalf of the current user (`records/accept`). + /// + /// Accepts each share identified by a short GUID, adding the caller as a + /// participant. The response mirrors ``resolveShares(_:)``, reporting the + /// caller's resulting ``ShareRecordInfo/participantStatus`` and + /// ``ShareRecordInfo/participantPermission`` for each share. + /// + /// Routed against the public database with web-auth credentials — there is + /// no current user to accept on behalf of without web-auth, so the database + /// is not exposed to callers. The service's `Credentials` must include an + /// `apiAuth` with a `webAuthToken`. + /// + /// - Parameter shortGUIDs: The short GUIDs identifying the shares to accept. + /// - Returns: One ``ShareRecordInfo`` per requested short GUID, in request + /// order. + /// - Throws: ``CloudKitError``. The endpoint validates the whole request, so + /// a bad or already-accepted short GUID fails the entire call rather than + /// producing a per-item failure. + public func acceptShares( + _ shortGUIDs: [ShortGUID] + ) async throws(CloudKitError) -> [ShareRecordInfo] { + do { + let client = try self.client(for: .public(.requires(.webAuth))) + let response = try await client.acceptShares( + .init( + path: Operations.acceptShares.Input.Path( + containerIdentifier: containerIdentifier, + environment: environment, + database: .public(.requires(.webAuth)) + ), + body: .json( + .init(shortGUIDs: shortGUIDs.map(Components.Schemas.ShortGUID.init(from:))) + ) + ) + ) + + let acceptData: Components.Schemas.ShortGUIDResultResponse = + try await responseProcessor.processAcceptSharesResponse(response) + return try (acceptData.results ?? []).map(ShareRecordInfo.init(from:)) + } catch { + throw mapToCloudKitError(error, context: "acceptShares") + } + } +} diff --git a/Sources/MistKit/Models/Environment.swift b/Sources/MistKit/Models/Environment.swift index f4198709..1cf42d18 100644 --- a/Sources/MistKit/Models/Environment.swift +++ b/Sources/MistKit/Models/Environment.swift @@ -30,7 +30,7 @@ internal import Foundation /// CloudKit environment types -public enum Environment: String, Sendable { +public enum Environment: String, Codable, Sendable { case development case production diff --git a/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift b/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift new file mode 100644 index 00000000..3125a66a --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift @@ -0,0 +1,63 @@ +// +// ShareAcceptanceStatus.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// Whether a participant has accepted a shared record. +public enum ShareAcceptanceStatus: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// The participant has been invited but has not yet accepted. + case invited = "INVITED" + /// The participant accepted the share. + case accepted = "ACCEPTED" + /// The participant was removed from the share. + case removed = "REMOVED" + /// CloudKit did not report a known acceptance status. + case unknown = "UNKNOWN" +} + +// MARK: - Internal Conversion +extension ShareAcceptanceStatus { + internal init(from payload: Components.Schemas.ShareParticipant.acceptanceStatusPayload) { + switch payload { + case .INVITED: self = .invited + case .ACCEPTED: self = .accepted + case .REMOVED: self = .removed + case .UNKNOWN: self = .unknown + } + } + + internal init(from payload: Components.Schemas.ShortGUIDResult.participantStatusPayload) { + switch payload { + case .INVITED: self = .invited + case .ACCEPTED: self = .accepted + case .REMOVED: self = .removed + case .UNKNOWN: self = .unknown + } + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareDatabaseScope.swift b/Sources/MistKit/Models/Sharing/ShareDatabaseScope.swift new file mode 100644 index 00000000..5404b51d --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareDatabaseScope.swift @@ -0,0 +1,56 @@ +// +// ShareDatabaseScope.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// The database scope that holds a shared record, as reported by +/// `records/resolve` and `records/accept`. +/// +/// Distinct from ``Database``: this is a plain descriptor CloudKit returns +/// about where the shared record lives, carrying no per-call +/// ``PublicAuthPreference``. +public enum ShareDatabaseScope: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// The public database. + case `public` = "PUBLIC" + /// The owner's private database. + case `private` = "PRIVATE" + /// The caller's shared database. + case shared = "SHARED" +} + +// MARK: - Internal Conversion +extension ShareDatabaseScope { + internal init(from payload: Components.Schemas.ShortGUIDResult.databaseScopePayload) { + switch payload { + case .PUBLIC: self = .public + case .PRIVATE: self = .private + case .SHARED: self = .shared + } + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareInfo.swift b/Sources/MistKit/Models/Sharing/ShareInfo.swift new file mode 100644 index 00000000..06d74ffb --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareInfo.swift @@ -0,0 +1,92 @@ +// +// ShareInfo.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// The share-specific keys carried by a `cloudKit.share` record. +/// +/// CloudKit returns these alongside the ordinary Record Dictionary keys on +/// share records. They are lifted out here because ``RecordInfo`` models a +/// plain record and intentionally carries no sharing metadata. +public struct ShareInfo: Codable, Sendable { + /// The short GUID identifying this share. + public let shortGUID: String? + /// The record name of the shared record this share governs. + public let sharedRecordName: String? + /// The public's read and write permissions on the shared record. + public let publicPermission: SharePermission? + /// The participants in the share. + public let participants: [ShareParticipant] + /// The owner of the shared record. + public let owner: ShareParticipant? + /// The current user's participation in the share. + public let currentUserParticipant: ShareParticipant? + + /// Initialize share information. + /// - Parameters: + /// - shortGUID: The short GUID identifying this share. + /// - sharedRecordName: The record name of the shared record. + /// - publicPermission: The public's permissions on the shared record. + /// - participants: The participants in the share. + /// - owner: The owner of the shared record. + /// - currentUserParticipant: The current user's participation. + public init( + shortGUID: String? = nil, + sharedRecordName: String? = nil, + publicPermission: SharePermission? = nil, + participants: [ShareParticipant] = [], + owner: ShareParticipant? = nil, + currentUserParticipant: ShareParticipant? = nil + ) { + self.shortGUID = shortGUID + self.sharedRecordName = sharedRecordName + self.publicPermission = publicPermission + self.participants = participants + self.owner = owner + self.currentUserParticipant = currentUserParticipant + } + + /// Lift the share-specific keys out of a record response, or return `nil` + /// when the record carries none of them (i.e. it is not a share record). + internal init?(from record: Components.Schemas.RecordResponse) { + let hasShareKeys = + record.shortGUID != nil || record.share != nil || record.publicPermission != nil + || record.participants != nil || record.owner != nil + || record.currentUserParticipant != nil + guard hasShareKeys else { + return nil + } + self.shortGUID = record.shortGUID + self.sharedRecordName = record.share?.recordName + self.publicPermission = record.publicPermission.map(SharePermission.init(from:)) + self.participants = record.participants?.map(ShareParticipant.init(from:)) ?? [] + self.owner = record.owner.map(ShareParticipant.init(from:)) + self.currentUserParticipant = record.currentUserParticipant.map(ShareParticipant.init(from:)) + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareParticipant.swift b/Sources/MistKit/Models/Sharing/ShareParticipant.swift new file mode 100644 index 00000000..a28c5063 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareParticipant.swift @@ -0,0 +1,71 @@ +// +// ShareParticipant.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A participant in a shared record. +/// +/// Participants appear on `cloudKit.share` records — as the `participants` +/// list, the share's `owner`, and the caller's own `currentUserParticipant` +/// entry. +public struct ShareParticipant: Codable, Sendable { + /// The identity of the participant, when CloudKit could resolve one. + public let userIdentity: UserIdentity? + /// The participant's read and write permissions. + public let permission: SharePermission? + /// The kind of participant. + public let type: ShareParticipantType? + /// Whether the participant has accepted the share. + public let acceptanceStatus: ShareAcceptanceStatus? + + /// Initialize a share participant. + /// - Parameters: + /// - userIdentity: The participant's identity. + /// - permission: The participant's read and write permissions. + /// - type: The kind of participant. + /// - acceptanceStatus: Whether the participant accepted the share. + public init( + userIdentity: UserIdentity? = nil, + permission: SharePermission? = nil, + type: ShareParticipantType? = nil, + acceptanceStatus: ShareAcceptanceStatus? = nil + ) { + self.userIdentity = userIdentity + self.permission = permission + self.type = type + self.acceptanceStatus = acceptanceStatus + } + + internal init(from schema: Components.Schemas.ShareParticipant) { + self.userIdentity = schema.userIdentity.map(UserIdentity.init(from:)) + self.permission = schema.permission.map(SharePermission.init(from:)) + self.type = schema._type.map(ShareParticipantType.init(from:)) + self.acceptanceStatus = schema.acceptanceStatus.map(ShareAcceptanceStatus.init(from:)) + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareParticipantType.swift b/Sources/MistKit/Models/Sharing/ShareParticipantType.swift new file mode 100644 index 00000000..c404bc82 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareParticipantType.swift @@ -0,0 +1,67 @@ +// +// ShareParticipantType.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// The kind of participant in a shared record. +public enum ShareParticipantType: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// The owner of the shared record. + case owner = "OWNER" + /// A participant who may modify the participant list. + case administrator = "ADMINISTRATOR" + /// A participant invited by identity. + case user = "USER" + /// A participant who arrived through the share's public permission. + case publicUser = "PUBLIC_USER" + /// CloudKit did not report a known participant type. + case unknown = "UNKNOWN" +} + +// MARK: - Internal Conversion +extension ShareParticipantType { + internal init(from payload: Components.Schemas.ShareParticipant._typePayload) { + switch payload { + case .OWNER: self = .owner + case .ADMINISTRATOR: self = .administrator + case .USER: self = .user + case .PUBLIC_USER: self = .publicUser + case .UNKNOWN: self = .unknown + } + } + + internal init(from payload: Components.Schemas.ShortGUIDResult.participantTypePayload) { + switch payload { + case .OWNER: self = .owner + case .ADMINISTRATOR: self = .administrator + case .USER: self = .user + case .PUBLIC_USER: self = .publicUser + case .UNKNOWN: self = .unknown + } + } +} diff --git a/Sources/MistKit/Models/Sharing/SharePermission.swift b/Sources/MistKit/Models/Sharing/SharePermission.swift new file mode 100644 index 00000000..5ba029e4 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/SharePermission.swift @@ -0,0 +1,73 @@ +// +// SharePermission.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A participant's — or the public's — read and write permissions on a +/// shared record. +public enum SharePermission: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// No access. + case none = "NONE" + /// Read-only access. + case readOnly = "READ_ONLY" + /// Read and write access. + case readWrite = "READ_WRITE" + /// CloudKit did not report a known permission. + case unknown = "UNKNOWN" +} + +// MARK: - Internal Conversion +extension SharePermission { + internal init(from payload: Components.Schemas.ShareParticipant.permissionPayload) { + switch payload { + case .NONE: self = .none + case .READ_ONLY: self = .readOnly + case .READ_WRITE: self = .readWrite + case .UNKNOWN: self = .unknown + } + } + + internal init(from payload: Components.Schemas.ShortGUIDResult.participantPermissionPayload) { + switch payload { + case .NONE: self = .none + case .READ_ONLY: self = .readOnly + case .READ_WRITE: self = .readWrite + case .UNKNOWN: self = .unknown + } + } + + internal init(from payload: Components.Schemas.RecordResponse.publicPermissionPayload) { + switch payload { + case .NONE: self = .none + case .READ_ONLY: self = .readOnly + case .READ_WRITE: self = .readWrite + case .UNKNOWN: self = .unknown + } + } +} diff --git a/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift new file mode 100644 index 00000000..1e76fa32 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift @@ -0,0 +1,75 @@ +// +// SharePotentialMatch.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A candidate participant offered when CloudKit cannot identify the caller +/// against exactly one invited participant. +/// +/// When ``ShareRecordInfo/potentialMatchList`` is non-empty the user must +/// choose which invitation they are claiming before the share can be accepted. +public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { + /// Contact details CloudKit holds for a potential participant. + public struct ContactInformation: Codable, Sendable, Equatable, Hashable { + /// The candidate's email address, when known. + public let emailAddress: String? + /// The candidate's phone number, when known. + public let phoneNumber: String? + + /// Initialize contact information. + /// - Parameters: + /// - emailAddress: The candidate's email address. + /// - phoneNumber: The candidate's phone number. + public init(emailAddress: String? = nil, phoneNumber: String? = nil) { + self.emailAddress = emailAddress + self.phoneNumber = phoneNumber + } + } + + /// The identifier to send back when claiming this invitation. + public let participantId: String? + /// Contact details CloudKit holds for this candidate. + public let contactInformation: ContactInformation? + + /// Initialize a potential match. + /// - Parameters: + /// - participantId: The identifier of the candidate participant. + /// - contactInformation: Contact details for the candidate. + public init(participantId: String? = nil, contactInformation: ContactInformation? = nil) { + self.participantId = participantId + self.contactInformation = contactInformation + } + + internal init(from schema: Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload) { + self.participantId = schema.participantId + self.contactInformation = schema.contactInformation.map { + ContactInformation(emailAddress: $0.emailAddress, phoneNumber: $0.phoneNumber) + } + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift new file mode 100644 index 00000000..1f255788 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -0,0 +1,168 @@ +// +// ShareRecordInfo.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// Information about a shared record, returned by `records/resolve` and +/// `records/accept`. +/// +/// One `ShareRecordInfo` is produced per requested ``ShortGUID``, in request +/// order. Every field is optional because CloudKit populates the result +/// differently depending on the operation and on whether the caller asked for +/// the root record (``ShortGUID/shouldFetchRootRecord``). +/// +/// When ``potentialMatchList`` is non-empty CloudKit could not identify the +/// caller against a single invited participant; the user must pick which +/// invitation they are claiming before the share can be accepted. +public struct ShareRecordInfo: Codable, Sendable { + /// The short GUID this result was resolved from. + public let shortGUID: ShortGUID? + /// The container holding the shared record. + public let containerIdentifier: String? + /// The database scope holding the shared record. + public let databaseScope: ShareDatabaseScope? + /// The container environment holding the shared record. + public let environment: Environment? + /// The zone holding the shared record. + public let zoneID: ZoneID? + /// The name of the root record that was shared. + public let rootRecordName: String? + /// The shared root record, when ``ShortGUID/shouldFetchRootRecord`` asked + /// for it. + public let rootRecord: RecordInfo? + /// The `cloudKit.share` record governing the share. + public let share: RecordInfo? + /// The share-specific keys lifted out of ``share`` — participants, the + /// owner, the public permission, and the caller's own participation. + public let shareInfo: ShareInfo? + /// The identity of the share's owner. + public let ownerIdentity: UserIdentity? + /// The caller's read and write permissions on the share. + public let participantPermission: SharePermission? + /// The caller's acceptance status for the share. + public let participantStatus: ShareAcceptanceStatus? + /// The caller's participant type for the share. + public let participantType: ShareParticipantType? + /// The fallback webpage configured in CloudKit Dashboard, used to direct + /// users somewhere when the operation fails. + public let webpageURL: String? + /// Candidate participants to choose from when the caller could not be + /// matched to exactly one invitation. + public let potentialMatchList: [SharePotentialMatch] + + internal init(from schema: Components.Schemas.ShortGUIDResult) throws(ConversionError) { + self.shortGUID = schema.shortGUID.map(ShortGUID.init(from:)) + self.containerIdentifier = schema.containerIdentifier + self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) + self.environment = schema.environment.map { + switch $0 { + case .development: Environment.development + case .production: Environment.production + } + } + self.zoneID = schema.zoneID.map { + ZoneID(zoneName: $0.zoneName ?? ZoneID.defaultZone.zoneName, ownerName: $0.ownerName) + } + self.rootRecordName = schema.rootRecordName + if let rootRecord = schema.rootRecord { + self.rootRecord = try RecordInfo(from: rootRecord) + } else { + self.rootRecord = nil + } + if let share = schema.share { + self.share = try RecordInfo(from: share) + self.shareInfo = ShareInfo(from: share) + } else { + self.share = nil + self.shareInfo = nil + } + self.ownerIdentity = schema.ownerIdentity.map(UserIdentity.init(from:)) + self.participantPermission = schema.participantPermission.map(SharePermission.init(from:)) + self.participantStatus = schema.participantStatus.map(ShareAcceptanceStatus.init(from:)) + self.participantType = schema.participantType.map(ShareParticipantType.init(from:)) + self.webpageURL = schema.webpageURL + self.potentialMatchList = + schema.potentialMatchList?.map(SharePotentialMatch.init(from:)) ?? [] + } + + /// Initialize share record information. + /// + /// Primarily intended for testing and for constructing values manually + /// rather than receiving them from CloudKit responses. + /// + /// - Parameters: + /// - shortGUID: The short GUID this result was resolved from. + /// - containerIdentifier: The container holding the shared record. + /// - databaseScope: The database scope holding the shared record. + /// - environment: The container environment holding the shared record. + /// - zoneID: The zone holding the shared record. + /// - rootRecordName: The name of the shared root record. + /// - rootRecord: The shared root record. + /// - share: The `cloudKit.share` record governing the share. + /// - shareInfo: The share-specific keys lifted out of `share`. + /// - ownerIdentity: The identity of the share's owner. + /// - participantPermission: The caller's permissions on the share. + /// - participantStatus: The caller's acceptance status. + /// - participantType: The caller's participant type. + /// - webpageURL: The dashboard-configured fallback webpage. + /// - potentialMatchList: Candidate participants to disambiguate the caller. + public init( + shortGUID: ShortGUID? = nil, + containerIdentifier: String? = nil, + databaseScope: ShareDatabaseScope? = nil, + environment: Environment? = nil, + zoneID: ZoneID? = nil, + rootRecordName: String? = nil, + rootRecord: RecordInfo? = nil, + share: RecordInfo? = nil, + shareInfo: ShareInfo? = nil, + ownerIdentity: UserIdentity? = nil, + participantPermission: SharePermission? = nil, + participantStatus: ShareAcceptanceStatus? = nil, + participantType: ShareParticipantType? = nil, + webpageURL: String? = nil, + potentialMatchList: [SharePotentialMatch] = [] + ) { + self.shortGUID = shortGUID + self.containerIdentifier = containerIdentifier + self.databaseScope = databaseScope + self.environment = environment + self.zoneID = zoneID + self.rootRecordName = rootRecordName + self.rootRecord = rootRecord + self.share = share + self.shareInfo = shareInfo + self.ownerIdentity = ownerIdentity + self.participantPermission = participantPermission + self.participantStatus = participantStatus + self.participantType = participantType + self.webpageURL = webpageURL + self.potentialMatchList = potentialMatchList + } +} diff --git a/Sources/MistKit/Models/Sharing/ShortGUID.swift b/Sources/MistKit/Models/Sharing/ShortGUID.swift new file mode 100644 index 00000000..7418f9fc --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShortGUID.swift @@ -0,0 +1,84 @@ +// +// ShortGUID.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A short global identifier for a shared record. +/// +/// CloudKit assigns a short GUID to a record when it is shared — either +/// explicitly, by setting `createShortGUID` when creating the record, or +/// implicitly, when a `cloudKit.share` record is created for it. The GUID is +/// what a share URL carries, and it is the handle used to resolve +/// (``CloudKitService/resolveShares(_:)``) and accept +/// (``CloudKitService/acceptShares(_:)``) a share. +public struct ShortGUID: Codable, Sendable, Equatable, Hashable { + /// The value of the short global ID. + public let value: String + /// Whether the root record should be fetched alongside the share. + /// + /// When `nil`, CloudKit applies its own default. + public let shouldFetchRootRecord: Bool? + /// Field names limiting the data returned in the root record. + /// + /// When `nil`, every field of the root record is returned. + public let rootRecordDesiredKeys: [String]? + + /// Initialize a short GUID. + /// - Parameters: + /// - value: The value of the short global ID. + /// - shouldFetchRootRecord: Whether to fetch the root record alongside + /// the share. + /// - rootRecordDesiredKeys: Field names limiting the root record payload. + public init( + value: String, + shouldFetchRootRecord: Bool? = nil, + rootRecordDesiredKeys: [String]? = nil + ) { + self.value = value + self.shouldFetchRootRecord = shouldFetchRootRecord + self.rootRecordDesiredKeys = rootRecordDesiredKeys + } + + internal init(from schema: Components.Schemas.ShortGUID) { + self.value = schema.value + self.shouldFetchRootRecord = schema.shouldFetchRootRecord + self.rootRecordDesiredKeys = schema.rootRecordDesiredKeys + } +} + +// MARK: - Internal Conversion +extension Components.Schemas.ShortGUID { + internal init(from shortGUID: ShortGUID) { + self.init( + value: shortGUID.value, + shouldFetchRootRecord: shortGUID.shouldFetchRootRecord, + rootRecordDesiredKeys: shortGUID.rootRecordDesiredKeys + ) + } +} diff --git a/Sources/MistKit/OpenAPI/OperationInputPath.swift b/Sources/MistKit/OpenAPI/OperationInputPath.swift index 2de8f39a..93aae9e2 100644 --- a/Sources/MistKit/OpenAPI/OperationInputPath.swift +++ b/Sources/MistKit/OpenAPI/OperationInputPath.swift @@ -94,3 +94,7 @@ extension Operations.listSubscriptions.Input.Path: OperationInputPath {} extension Operations.lookupSubscriptions.Input.Path: OperationInputPath {} extension Operations.modifySubscriptions.Input.Path: OperationInputPath {} + +extension Operations.resolveShortGUIDs.Input.Path: OperationInputPath {} + +extension Operations.acceptShares.Input.Path: OperationInputPath {} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.acceptShares.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.acceptShares.Output.swift new file mode 100644 index 00000000..3460825a --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.acceptShares.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.acceptShares.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.acceptShares.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKit/OpenAPI/Operations/Operations.resolveShortGUIDs.Output.swift b/Sources/MistKit/OpenAPI/Operations/Operations.resolveShortGUIDs.Output.swift new file mode 100644 index 00000000..e11ea9bb --- /dev/null +++ b/Sources/MistKit/OpenAPI/Operations/Operations.resolveShortGUIDs.Output.swift @@ -0,0 +1,52 @@ +// +// Operations.resolveShortGUIDs.Output.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +extension Operations.resolveShortGUIDs.Output: CloudKitResponseType { + // swiftlint:disable:next cyclomatic_complexity + internal func toCloudKitError() -> CloudKitError? { + switch self { + case .ok: return nil + case .badRequest(let response): return .init(response, statusCode: 400) + case .unauthorized(let response): return .init(response, statusCode: 401) + case .forbidden(let response): return .init(response, statusCode: 403) + case .notFound(let response): return .init(response, statusCode: 404) + case .conflict(let response): return .init(response, statusCode: 409) + case .preconditionFailed(let response): return .init(response, statusCode: 412) + case .contentTooLarge(let response): return .init(response, statusCode: 413) + case .misdirectedRequest(let response): return .init(response, statusCode: 421) + case .tooManyRequests(let response): return .init(response, statusCode: 429) + case .internalServerError(let response): return .init(response, statusCode: 500) + case .serviceUnavailable(let response): return .init(response, statusCode: 503) + case .undocumented(let statusCode, _): + return .undocumented(statusCode: statusCode, response: self) + } + } +} diff --git a/Sources/MistKitOpenAPI/Client.swift b/Sources/MistKitOpenAPI/Client.swift index 114b8900..78c69b1f 100644 --- a/Sources/MistKitOpenAPI/Client.swift +++ b/Sources/MistKitOpenAPI/Client.swift @@ -1010,6 +1010,664 @@ public struct Client: APIProtocol { } ) } + /// Fetch Record Information + /// + /// Resolve one or more share short GUIDs into information about the shared + /// records they identify — the root record, the `cloudKit.share` record, + /// the owner identity, and the caller's participation in each share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and the operation resolves shares on behalf of + /// the *current* user. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`FetchingRecordInformation`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/resolve`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)`. + public func resolveShortGUIDs(_ input: Operations.resolveShortGUIDs.Input) async throws -> Operations.resolveShortGUIDs.Output { + try await client.send( + input: input, + forOperation: Operations.resolveShortGUIDs.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/records/resolve", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.resolveShortGUIDs.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ShortGUIDResultResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 403: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .forbidden(.init(body: body)) + case 404: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .notFound(.init(body: body)) + case 409: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .conflict(.init(body: body)) + case 412: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .preconditionFailed(.init(body: body)) + case 413: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .contentTooLarge(.init(body: body)) + case 429: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .tooManyRequests(.init(body: body)) + case 421: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .misdirectedRequest(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + case 503: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .serviceUnavailable(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } + /// Accept Share Records + /// + /// Accept one or more shares — each identified by a short GUID — on behalf + /// of the current user. The response mirrors `records/resolve`, reporting + /// the caller's participation status for each accepted share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and there is no current user to accept on + /// behalf of without web-auth. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`AcceptingShareRecords`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/accept`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)`. + public func acceptShares(_ input: Operations.acceptShares.Input) async throws -> Operations.acceptShares.Output { + try await client.send( + input: input, + forOperation: Operations.acceptShares.id, + serializer: { input in + let path = try converter.renderedPath( + template: "/database/{}/{}/{}/{}/records/accept", + parameters: [ + input.path.version, + input.path.container, + input.path.environment, + input.path.database + ] + ) + var request: HTTPTypes.HTTPRequest = .init( + soar_path: path, + method: .post + ) + suppressMutabilityWarning(&request) + converter.setAcceptHeader( + in: &request.headerFields, + contentTypes: input.headers.accept + ) + let body: OpenAPIRuntime.HTTPBody? + switch input.body { + case let .json(value): + body = try converter.setRequiredRequestBodyAsJSON( + value, + headerFields: &request.headerFields, + contentType: "application/json; charset=utf-8" + ) + } + return (request, body) + }, + deserializer: { response, responseBody in + switch response.status.code { + case 200: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Operations.acceptShares.Output.Ok.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ShortGUIDResultResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .ok(.init(body: body)) + case 400: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .badRequest(.init(body: body)) + case 401: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .unauthorized(.init(body: body)) + case 403: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .forbidden(.init(body: body)) + case 404: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .notFound(.init(body: body)) + case 409: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .conflict(.init(body: body)) + case 412: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .preconditionFailed(.init(body: body)) + case 413: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .contentTooLarge(.init(body: body)) + case 429: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .tooManyRequests(.init(body: body)) + case 421: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .misdirectedRequest(.init(body: body)) + case 500: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .internalServerError(.init(body: body)) + case 503: + let contentType = converter.extractContentTypeIfPresent(in: response.headerFields) + let body: Components.Responses.Failure.Body + let chosenContentType = try converter.bestContentType( + received: contentType, + options: [ + "application/json" + ] + ) + switch chosenContentType { + case "application/json": + body = try await converter.getResponseBodyAsJSON( + Components.Schemas.ErrorResponse.self, + from: responseBody, + transforming: { value in + .json(value) + } + ) + default: + preconditionFailure("bestContentType chose an invalid content type.") + } + return .serviceUnavailable(.init(body: body)) + default: + return .undocumented( + statusCode: response.status.code, + .init( + headerFields: response.headerFields, + body: responseBody + ) + ) + } + } + ) + } /// Fetch Record Changes /// /// Get all record changes relative to a sync token diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index 37a73e13..97dbbb1d 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -34,6 +34,42 @@ public protocol APIProtocol: Sendable { /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/lookup`. /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/lookup/post(lookupRecords)`. func lookupRecords(_ input: Operations.lookupRecords.Input) async throws -> Operations.lookupRecords.Output + /// Fetch Record Information + /// + /// Resolve one or more share short GUIDs into information about the shared + /// records they identify — the root record, the `cloudKit.share` record, + /// the owner identity, and the caller's participation in each share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and the operation resolves shares on behalf of + /// the *current* user. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`FetchingRecordInformation`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/resolve`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)`. + func resolveShortGUIDs(_ input: Operations.resolveShortGUIDs.Input) async throws -> Operations.resolveShortGUIDs.Output + /// Accept Share Records + /// + /// Accept one or more shares — each identified by a short GUID — on behalf + /// of the current user. The response mirrors `records/resolve`, reporting + /// the caller's participation status for each accepted share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and there is no current user to accept on + /// behalf of without web-auth. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`AcceptingShareRecords`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/accept`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)`. + func acceptShares(_ input: Operations.acceptShares.Input) async throws -> Operations.acceptShares.Output /// Fetch Record Changes /// /// Get all record changes relative to a sync token @@ -253,6 +289,62 @@ extension APIProtocol { body: body )) } + /// Fetch Record Information + /// + /// Resolve one or more share short GUIDs into information about the shared + /// records they identify — the root record, the `cloudKit.share` record, + /// the owner identity, and the caller's participation in each share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and the operation resolves shares on behalf of + /// the *current* user. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`FetchingRecordInformation`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/resolve`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)`. + public func resolveShortGUIDs( + path: Operations.resolveShortGUIDs.Input.Path, + headers: Operations.resolveShortGUIDs.Input.Headers = .init(), + body: Operations.resolveShortGUIDs.Input.Body + ) async throws -> Operations.resolveShortGUIDs.Output { + try await resolveShortGUIDs(Operations.resolveShortGUIDs.Input( + path: path, + headers: headers, + body: body + )) + } + /// Accept Share Records + /// + /// Accept one or more shares — each identified by a short GUID — on behalf + /// of the current user. The response mirrors `records/resolve`, reporting + /// the caller's participation status for each accepted share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and there is no current user to accept on + /// behalf of without web-auth. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`AcceptingShareRecords`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/accept`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)`. + public func acceptShares( + path: Operations.acceptShares.Input.Path, + headers: Operations.acceptShares.Input.Headers = .init(), + body: Operations.acceptShares.Input.Body + ) async throws -> Operations.acceptShares.Output { + try await acceptShares(Operations.acceptShares.Input( + path: path, + headers: headers, + body: body + )) + } /// Fetch Record Changes /// /// Get all record changes relative to a sync token @@ -818,6 +910,37 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/RecordRequest/fields`. public var fields: Components.Schemas.RecordRequest.fieldsPayload? + /// Whether to create a short GUID so this record can be shared. The + /// response echoes the generated GUID back as `shortGUID`. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/createShortGUID`. + public var createShortGUID: Swift.Bool? + /// - Remark: Generated from `#/components/schemas/RecordRequest/forRecord`. + public var forRecord: Components.Schemas.ShareTargetReference? + /// The public read/write permissions to apply. Set when creating a + /// `cloudKit.share` record. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/publicPermission`. + @frozen public enum publicPermissionPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case READ_ONLY = "READ_ONLY" + case READ_WRITE = "READ_WRITE" + case UNKNOWN = "UNKNOWN" + } + /// The public read/write permissions to apply. Set when creating a + /// `cloudKit.share` record. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/publicPermission`. + public var publicPermission: Components.Schemas.RecordRequest.publicPermissionPayload? + /// The participants to invite. Set when creating a `cloudKit.share` + /// record. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordRequest/participants`. + public var participants: [Components.Schemas.ShareParticipant]? /// Creates a new `RecordRequest`. /// /// - Parameters: @@ -825,22 +948,65 @@ public enum Components { /// - recordType: The record type (schema name) /// - recordChangeTag: Change tag for optimistic concurrency control /// - fields: Record fields with their values (no type metadata) + /// - createShortGUID: Whether to create a short GUID so this record can be shared. The + /// - forRecord: + /// - publicPermission: The public read/write permissions to apply. Set when creating a + /// - participants: The participants to invite. Set when creating a `cloudKit.share` public init( recordName: Swift.String? = nil, recordType: Swift.String? = nil, recordChangeTag: Swift.String? = nil, - fields: Components.Schemas.RecordRequest.fieldsPayload? = nil + fields: Components.Schemas.RecordRequest.fieldsPayload? = nil, + createShortGUID: Swift.Bool? = nil, + forRecord: Components.Schemas.ShareTargetReference? = nil, + publicPermission: Components.Schemas.RecordRequest.publicPermissionPayload? = nil, + participants: [Components.Schemas.ShareParticipant]? = nil ) { self.recordName = recordName self.recordType = recordType self.recordChangeTag = recordChangeTag self.fields = fields + self.createShortGUID = createShortGUID + self.forRecord = forRecord + self.publicPermission = publicPermission + self.participants = participants } public enum CodingKeys: String, CodingKey { case recordName case recordType case recordChangeTag case fields + case createShortGUID + case forRecord + case publicPermission + case participants + } + } + /// Identifies the record being shared when creating a `cloudKit.share` + /// record (the `forRecord` key). + /// + /// + /// - Remark: Generated from `#/components/schemas/ShareTargetReference`. + public struct ShareTargetReference: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShareTargetReference/recordName`. + public var recordName: Swift.String + /// - Remark: Generated from `#/components/schemas/ShareTargetReference/recordChangeTag`. + public var recordChangeTag: Swift.String? + /// Creates a new `ShareTargetReference`. + /// + /// - Parameters: + /// - recordName: + /// - recordChangeTag: + public init( + recordName: Swift.String, + recordChangeTag: Swift.String? = nil + ) { + self.recordName = recordName + self.recordChangeTag = recordChangeTag + } + public enum CodingKeys: String, CodingKey { + case recordName + case recordChangeTag } } /// Record schema for API responses (fields use FieldValueResponse) @@ -891,6 +1057,41 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/RecordResponse/deleted`. public var deleted: Swift.Bool? + /// The short GUID of a shared record. Present only on records that + /// have been shared (see `createShortGUID` on the request side). + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/shortGUID`. + public var shortGUID: Swift.String? + /// - Remark: Generated from `#/components/schemas/RecordResponse/share`. + public var share: Components.Schemas.ShareReference? + /// The public read/write permissions of a shared record. Present on + /// `cloudKit.share` records. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/publicPermission`. + @frozen public enum publicPermissionPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case READ_ONLY = "READ_ONLY" + case READ_WRITE = "READ_WRITE" + case UNKNOWN = "UNKNOWN" + } + /// The public read/write permissions of a shared record. Present on + /// `cloudKit.share` records. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/publicPermission`. + public var publicPermission: Components.Schemas.RecordResponse.publicPermissionPayload? + /// The participants in a shared record. Present on `cloudKit.share` + /// records. + /// + /// + /// - Remark: Generated from `#/components/schemas/RecordResponse/participants`. + public var participants: [Components.Schemas.ShareParticipant]? + /// - Remark: Generated from `#/components/schemas/RecordResponse/owner`. + public var owner: Components.Schemas.ShareParticipant? + /// - Remark: Generated from `#/components/schemas/RecordResponse/currentUserParticipant`. + public var currentUserParticipant: Components.Schemas.ShareParticipant? /// Creates a new `RecordResponse`. /// /// - Parameters: @@ -901,6 +1102,12 @@ public enum Components { /// - created: /// - modified: /// - deleted: Whether the record was deleted + /// - shortGUID: The short GUID of a shared record. Present only on records that + /// - share: + /// - publicPermission: The public read/write permissions of a shared record. Present on + /// - participants: The participants in a shared record. Present on `cloudKit.share` + /// - owner: + /// - currentUserParticipant: public init( recordName: Swift.String? = nil, recordType: Swift.String? = nil, @@ -908,7 +1115,13 @@ public enum Components { fields: Components.Schemas.RecordResponse.fieldsPayload? = nil, created: Components.Schemas.RecordTimestamp? = nil, modified: Components.Schemas.RecordTimestamp? = nil, - deleted: Swift.Bool? = nil + deleted: Swift.Bool? = nil, + shortGUID: Swift.String? = nil, + share: Components.Schemas.ShareReference? = nil, + publicPermission: Components.Schemas.RecordResponse.publicPermissionPayload? = nil, + participants: [Components.Schemas.ShareParticipant]? = nil, + owner: Components.Schemas.ShareParticipant? = nil, + currentUserParticipant: Components.Schemas.ShareParticipant? = nil ) { self.recordName = recordName self.recordType = recordType @@ -917,6 +1130,12 @@ public enum Components { self.created = created self.modified = modified self.deleted = deleted + self.shortGUID = shortGUID + self.share = share + self.publicPermission = publicPermission + self.participants = participants + self.owner = owner + self.currentUserParticipant = currentUserParticipant } public enum CodingKeys: String, CodingKey { case recordName @@ -926,6 +1145,30 @@ public enum Components { case created case modified case deleted + case shortGUID + case share + case publicPermission + case participants + case owner + case currentUserParticipant + } + } + /// A reference to the `cloudKit.share` record governing a shared record. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShareReference`. + public struct ShareReference: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShareReference/recordName`. + public var recordName: Swift.String? + /// Creates a new `ShareReference`. + /// + /// - Parameters: + /// - recordName: + public init(recordName: Swift.String? = nil) { + self.recordName = recordName + } + public enum CodingKeys: String, CodingKey { + case recordName } } /// A CloudKit field value for API requests. @@ -2453,6 +2696,353 @@ public enum Components { case users } } + /// A short global identifier for a shared record, used to resolve + /// (`records/resolve`) and accept (`records/accept`) shares. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUID`. + public struct ShortGUID: Codable, Hashable, Sendable { + /// The value of the short global ID. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUID/value`. + public var value: Swift.String + /// Whether the root record should be fetched alongside the share. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUID/shouldFetchRootRecord`. + public var shouldFetchRootRecord: Swift.Bool? + /// Field names limiting the data returned in the root record. When + /// omitted, every field of the root record is returned. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUID/rootRecordDesiredKeys`. + public var rootRecordDesiredKeys: [Swift.String]? + /// Creates a new `ShortGUID`. + /// + /// - Parameters: + /// - value: The value of the short global ID. + /// - shouldFetchRootRecord: Whether the root record should be fetched alongside the share. + /// - rootRecordDesiredKeys: Field names limiting the data returned in the root record. When + public init( + value: Swift.String, + shouldFetchRootRecord: Swift.Bool? = nil, + rootRecordDesiredKeys: [Swift.String]? = nil + ) { + self.value = value + self.shouldFetchRootRecord = shouldFetchRootRecord + self.rootRecordDesiredKeys = rootRecordDesiredKeys + } + public enum CodingKeys: String, CodingKey { + case value + case shouldFetchRootRecord + case rootRecordDesiredKeys + } + } + /// A participant in a shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant`. + public struct ShareParticipant: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShareParticipant/userIdentity`. + public var userIdentity: Components.Schemas.UserIdentity? + /// The participant's read and write permissions. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/permission`. + @frozen public enum permissionPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case READ_ONLY = "READ_ONLY" + case READ_WRITE = "READ_WRITE" + case UNKNOWN = "UNKNOWN" + } + /// The participant's read and write permissions. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/permission`. + public var permission: Components.Schemas.ShareParticipant.permissionPayload? + /// The type of participant. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/type`. + @frozen public enum _typePayload: String, Codable, Hashable, Sendable, CaseIterable { + case OWNER = "OWNER" + case ADMINISTRATOR = "ADMINISTRATOR" + case USER = "USER" + case PUBLIC_USER = "PUBLIC_USER" + case UNKNOWN = "UNKNOWN" + } + /// The type of participant. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/type`. + public var _type: Components.Schemas.ShareParticipant._typePayload? + /// The status of the participant accepting the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/acceptanceStatus`. + @frozen public enum acceptanceStatusPayload: String, Codable, Hashable, Sendable, CaseIterable { + case INVITED = "INVITED" + case ACCEPTED = "ACCEPTED" + case REMOVED = "REMOVED" + case UNKNOWN = "UNKNOWN" + } + /// The status of the participant accepting the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShareParticipant/acceptanceStatus`. + public var acceptanceStatus: Components.Schemas.ShareParticipant.acceptanceStatusPayload? + /// Creates a new `ShareParticipant`. + /// + /// - Parameters: + /// - userIdentity: + /// - permission: The participant's read and write permissions. + /// - _type: The type of participant. + /// - acceptanceStatus: The status of the participant accepting the shared record. + public init( + userIdentity: Components.Schemas.UserIdentity? = nil, + permission: Components.Schemas.ShareParticipant.permissionPayload? = nil, + _type: Components.Schemas.ShareParticipant._typePayload? = nil, + acceptanceStatus: Components.Schemas.ShareParticipant.acceptanceStatusPayload? = nil + ) { + self.userIdentity = userIdentity + self.permission = permission + self._type = _type + self.acceptanceStatus = acceptanceStatus + } + public enum CodingKeys: String, CodingKey { + case userIdentity + case permission + case _type = "type" + case acceptanceStatus + } + } + /// The result of resolving or accepting a single share, as returned by + /// `records/resolve` and `records/accept`. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult`. + public struct ShortGUIDResult: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/shortGUID`. + public var shortGUID: Components.Schemas.ShortGUID? + /// The container holding the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/containerIdentifier`. + public var containerIdentifier: Swift.String? + /// The database scope holding the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/databaseScope`. + @frozen public enum databaseScopePayload: String, Codable, Hashable, Sendable, CaseIterable { + case PUBLIC = "PUBLIC" + case PRIVATE = "PRIVATE" + case SHARED = "SHARED" + } + /// The database scope holding the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/databaseScope`. + public var databaseScope: Components.Schemas.ShortGUIDResult.databaseScopePayload? + /// The container environment holding the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/environment`. + @frozen public enum environmentPayload: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// The container environment holding the shared record. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/environment`. + public var environment: Components.Schemas.ShortGUIDResult.environmentPayload? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/zoneID`. + public var zoneID: Components.Schemas.ZoneID? + /// The name of the root record that was shared. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/rootRecordName`. + public var rootRecordName: Swift.String? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/rootRecord`. + public var rootRecord: Components.Schemas.RecordResponse? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/share`. + public var share: Components.Schemas.RecordResponse? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/ownerIdentity`. + public var ownerIdentity: Components.Schemas.UserIdentity? + /// The caller's read and write permissions on the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantPermission`. + @frozen public enum participantPermissionPayload: String, Codable, Hashable, Sendable, CaseIterable { + case NONE = "NONE" + case READ_ONLY = "READ_ONLY" + case READ_WRITE = "READ_WRITE" + case UNKNOWN = "UNKNOWN" + } + /// The caller's read and write permissions on the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantPermission`. + public var participantPermission: Components.Schemas.ShortGUIDResult.participantPermissionPayload? + /// The caller's acceptance status for the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantStatus`. + @frozen public enum participantStatusPayload: String, Codable, Hashable, Sendable, CaseIterable { + case INVITED = "INVITED" + case ACCEPTED = "ACCEPTED" + case REMOVED = "REMOVED" + case UNKNOWN = "UNKNOWN" + } + /// The caller's acceptance status for the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantStatus`. + public var participantStatus: Components.Schemas.ShortGUIDResult.participantStatusPayload? + /// The caller's participant type for the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantType`. + @frozen public enum participantTypePayload: String, Codable, Hashable, Sendable, CaseIterable { + case OWNER = "OWNER" + case ADMINISTRATOR = "ADMINISTRATOR" + case USER = "USER" + case PUBLIC_USER = "PUBLIC_USER" + case UNKNOWN = "UNKNOWN" + } + /// The caller's participant type for the share. + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/participantType`. + public var participantType: Components.Schemas.ShortGUIDResult.participantTypePayload? + /// The fallback webpage configured in CloudKit Dashboard, used to + /// direct users somewhere when the operation fails. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/webpageURL`. + public var webpageURL: Swift.String? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload`. + public struct potentialMatchListPayloadPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload/participantId`. + public var participantId: Swift.String? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload/contactInformation`. + public struct contactInformationPayload: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload/contactInformation/emailAddress`. + public var emailAddress: Swift.String? + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload/contactInformation/phoneNumber`. + public var phoneNumber: Swift.String? + /// Creates a new `contactInformationPayload`. + /// + /// - Parameters: + /// - emailAddress: + /// - phoneNumber: + public init( + emailAddress: Swift.String? = nil, + phoneNumber: Swift.String? = nil + ) { + self.emailAddress = emailAddress + self.phoneNumber = phoneNumber + } + public enum CodingKeys: String, CodingKey { + case emailAddress + case phoneNumber + } + } + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchListPayload/contactInformation`. + public var contactInformation: Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload.contactInformationPayload? + /// Creates a new `potentialMatchListPayloadPayload`. + /// + /// - Parameters: + /// - participantId: + /// - contactInformation: + public init( + participantId: Swift.String? = nil, + contactInformation: Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload.contactInformationPayload? = nil + ) { + self.participantId = participantId + self.contactInformation = contactInformation + } + public enum CodingKeys: String, CodingKey { + case participantId + case contactInformation + } + } + /// When the participant is not identifiable, the potential + /// participants the user can choose from. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchList`. + public typealias potentialMatchListPayload = [Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload] + /// When the participant is not identifiable, the potential + /// participants the user can choose from. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResult/potentialMatchList`. + public var potentialMatchList: Components.Schemas.ShortGUIDResult.potentialMatchListPayload? + /// Creates a new `ShortGUIDResult`. + /// + /// - Parameters: + /// - shortGUID: + /// - containerIdentifier: The container holding the shared record. + /// - databaseScope: The database scope holding the shared record. + /// - environment: The container environment holding the shared record. + /// - zoneID: + /// - rootRecordName: The name of the root record that was shared. + /// - rootRecord: + /// - share: + /// - ownerIdentity: + /// - participantPermission: The caller's read and write permissions on the share. + /// - participantStatus: The caller's acceptance status for the share. + /// - participantType: The caller's participant type for the share. + /// - webpageURL: The fallback webpage configured in CloudKit Dashboard, used to + /// - potentialMatchList: When the participant is not identifiable, the potential + public init( + shortGUID: Components.Schemas.ShortGUID? = nil, + containerIdentifier: Swift.String? = nil, + databaseScope: Components.Schemas.ShortGUIDResult.databaseScopePayload? = nil, + environment: Components.Schemas.ShortGUIDResult.environmentPayload? = nil, + zoneID: Components.Schemas.ZoneID? = nil, + rootRecordName: Swift.String? = nil, + rootRecord: Components.Schemas.RecordResponse? = nil, + share: Components.Schemas.RecordResponse? = nil, + ownerIdentity: Components.Schemas.UserIdentity? = nil, + participantPermission: Components.Schemas.ShortGUIDResult.participantPermissionPayload? = nil, + participantStatus: Components.Schemas.ShortGUIDResult.participantStatusPayload? = nil, + participantType: Components.Schemas.ShortGUIDResult.participantTypePayload? = nil, + webpageURL: Swift.String? = nil, + potentialMatchList: Components.Schemas.ShortGUIDResult.potentialMatchListPayload? = nil + ) { + self.shortGUID = shortGUID + self.containerIdentifier = containerIdentifier + self.databaseScope = databaseScope + self.environment = environment + self.zoneID = zoneID + self.rootRecordName = rootRecordName + self.rootRecord = rootRecord + self.share = share + self.ownerIdentity = ownerIdentity + self.participantPermission = participantPermission + self.participantStatus = participantStatus + self.participantType = participantType + self.webpageURL = webpageURL + self.potentialMatchList = potentialMatchList + } + public enum CodingKeys: String, CodingKey { + case shortGUID + case containerIdentifier + case databaseScope + case environment + case zoneID + case rootRecordName + case rootRecord + case share + case ownerIdentity + case participantPermission + case participantStatus + case participantType + case webpageURL + case potentialMatchList + } + } + /// The response body for `records/resolve` and `records/accept`. + /// + /// + /// - Remark: Generated from `#/components/schemas/ShortGUIDResultResponse`. + public struct ShortGUIDResultResponse: Codable, Hashable, Sendable { + /// - Remark: Generated from `#/components/schemas/ShortGUIDResultResponse/results`. + public var results: [Components.Schemas.ShortGUIDResult]? + /// Creates a new `ShortGUIDResultResponse`. + /// + /// - Parameters: + /// - results: + public init(results: [Components.Schemas.ShortGUIDResult]? = nil) { + self.results = results + } + public enum CodingKeys: String, CodingKey { + case results + } + } /// - Remark: Generated from `#/components/schemas/ContactsResponse`. public struct ContactsResponse: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ContactsResponse/contacts`. @@ -4777,6 +5367,1240 @@ public enum Operations { } } } + /// Fetch Record Information + /// + /// Resolve one or more share short GUIDs into information about the shared + /// records they identify — the root record, the `cloudKit.share` record, + /// the owner identity, and the caller's participation in each share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and the operation resolves shares on behalf of + /// the *current* user. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`FetchingRecordInformation`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/resolve`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)`. + public enum resolveShortGUIDs { + public static let id: Swift.String = "resolveShortGUIDs" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.resolveShortGUIDs.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.resolveShortGUIDs.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// The short GUIDs identifying the shares to resolve. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/requestBody/json/shortGUIDs`. + public var shortGUIDs: [Components.Schemas.ShortGUID] + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - shortGUIDs: The short GUIDs identifying the shares to resolve. + public init(shortGUIDs: [Components.Schemas.ShortGUID]) { + self.shortGUIDs = shortGUIDs + } + public enum CodingKeys: String, CodingKey { + case shortGUIDs + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/requestBody/content/application\/json`. + case json(Operations.resolveShortGUIDs.Input.Body.jsonPayload) + } + public var body: Operations.resolveShortGUIDs.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.resolveShortGUIDs.Input.Path, + headers: Operations.resolveShortGUIDs.Input.Headers = .init(), + body: Operations.resolveShortGUIDs.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/resolve/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ShortGUIDResultResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ShortGUIDResultResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.resolveShortGUIDs.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.resolveShortGUIDs.Output.Ok.Body) { + self.body = body + } + } + /// Short GUIDs resolved successfully. + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.resolveShortGUIDs.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.resolveShortGUIDs.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/resolve/post(resolveShortGUIDs)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } + /// Accept Share Records + /// + /// Accept one or more shares — each identified by a short GUID — on behalf + /// of the current user. The response mirrors `records/resolve`, reporting + /// the caller's participation status for each accepted share. + /// + /// Routed against the public database with web-auth credentials + /// (user-context auth): Apple's reference documents the path with a fixed + /// `public` database scope, and there is no current user to accept on + /// behalf of without web-auth. + /// + /// Documented in Apple's archived CloudKit Web Services Reference + /// (`AcceptingShareRecords`); absent from the current online docs. + /// + /// + /// - Remark: HTTP `POST /database/{version}/{container}/{environment}/{database}/records/accept`. + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)`. + public enum acceptShares { + public static let id: Swift.String = "acceptShares" + public struct Input: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/path`. + public struct Path: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/path/version`. + public var version: Components.Parameters.version + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/path/container`. + public var container: Components.Parameters.container + /// Container environment + /// + /// - Remark: Generated from `#/components/parameters/environment`. + @frozen public enum environment: String, Codable, Hashable, Sendable, CaseIterable { + case development = "development" + case production = "production" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/path/environment`. + public var environment: Components.Parameters.environment + /// Database scope + /// + /// - Remark: Generated from `#/components/parameters/database`. + @frozen public enum database: String, Codable, Hashable, Sendable, CaseIterable { + case _public = "public" + case _private = "private" + case shared = "shared" + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/path/database`. + public var database: Components.Parameters.database + /// Creates a new `Path`. + /// + /// - Parameters: + /// - version: + /// - container: + /// - environment: + /// - database: + public init( + version: Components.Parameters.version, + container: Components.Parameters.container, + environment: Components.Parameters.environment, + database: Components.Parameters.database + ) { + self.version = version + self.container = container + self.environment = environment + self.database = database + } + } + public var path: Operations.acceptShares.Input.Path + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/header`. + public struct Headers: Sendable, Hashable { + public var accept: [OpenAPIRuntime.AcceptHeaderContentType] + /// Creates a new `Headers`. + /// + /// - Parameters: + /// - accept: + public init(accept: [OpenAPIRuntime.AcceptHeaderContentType] = .defaultValues()) { + self.accept = accept + } + } + public var headers: Operations.acceptShares.Input.Headers + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/requestBody`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/requestBody/json`. + public struct jsonPayload: Codable, Hashable, Sendable { + /// The short GUIDs identifying the shares to accept. + /// + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/requestBody/json/shortGUIDs`. + public var shortGUIDs: [Components.Schemas.ShortGUID] + /// Creates a new `jsonPayload`. + /// + /// - Parameters: + /// - shortGUIDs: The short GUIDs identifying the shares to accept. + public init(shortGUIDs: [Components.Schemas.ShortGUID]) { + self.shortGUIDs = shortGUIDs + } + public enum CodingKeys: String, CodingKey { + case shortGUIDs + } + } + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/requestBody/content/application\/json`. + case json(Operations.acceptShares.Input.Body.jsonPayload) + } + public var body: Operations.acceptShares.Input.Body + /// Creates a new `Input`. + /// + /// - Parameters: + /// - path: + /// - headers: + /// - body: + public init( + path: Operations.acceptShares.Input.Path, + headers: Operations.acceptShares.Input.Headers = .init(), + body: Operations.acceptShares.Input.Body + ) { + self.path = path + self.headers = headers + self.body = body + } + } + @frozen public enum Output: Sendable, Hashable { + public struct Ok: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/responses/200/content`. + @frozen public enum Body: Sendable, Hashable { + /// - Remark: Generated from `#/paths/database/{version}/{container}/{environment}/{database}/records/accept/POST/responses/200/content/application\/json`. + case json(Components.Schemas.ShortGUIDResultResponse) + /// The associated value of the enum case if `self` is `.json`. + /// + /// - Throws: An error if `self` is not `.json`. + /// - SeeAlso: `.json`. + public var json: Components.Schemas.ShortGUIDResultResponse { + get throws { + switch self { + case let .json(body): + return body + } + } + } + } + /// Received HTTP response body + public var body: Operations.acceptShares.Output.Ok.Body + /// Creates a new `Ok`. + /// + /// - Parameters: + /// - body: Received HTTP response body + public init(body: Operations.acceptShares.Output.Ok.Body) { + self.body = body + } + } + /// Shares accepted successfully. + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/200`. + /// + /// HTTP response code: `200 ok`. + case ok(Operations.acceptShares.Output.Ok) + /// The associated value of the enum case if `self` is `.ok`. + /// + /// - Throws: An error if `self` is not `.ok`. + /// - SeeAlso: `.ok`. + public var ok: Operations.acceptShares.Output.Ok { + get throws { + switch self { + case let .ok(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "ok", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/400`. + /// + /// HTTP response code: `400 badRequest`. + case badRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.badRequest`. + /// + /// - Throws: An error if `self` is not `.badRequest`. + /// - SeeAlso: `.badRequest`. + public var badRequest: Components.Responses.Failure { + get throws { + switch self { + case let .badRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "badRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/401`. + /// + /// HTTP response code: `401 unauthorized`. + case unauthorized(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.unauthorized`. + /// + /// - Throws: An error if `self` is not `.unauthorized`. + /// - SeeAlso: `.unauthorized`. + public var unauthorized: Components.Responses.Failure { + get throws { + switch self { + case let .unauthorized(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "unauthorized", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/403`. + /// + /// HTTP response code: `403 forbidden`. + case forbidden(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.forbidden`. + /// + /// - Throws: An error if `self` is not `.forbidden`. + /// - SeeAlso: `.forbidden`. + public var forbidden: Components.Responses.Failure { + get throws { + switch self { + case let .forbidden(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "forbidden", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/404`. + /// + /// HTTP response code: `404 notFound`. + case notFound(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.notFound`. + /// + /// - Throws: An error if `self` is not `.notFound`. + /// - SeeAlso: `.notFound`. + public var notFound: Components.Responses.Failure { + get throws { + switch self { + case let .notFound(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "notFound", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/409`. + /// + /// HTTP response code: `409 conflict`. + case conflict(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.conflict`. + /// + /// - Throws: An error if `self` is not `.conflict`. + /// - SeeAlso: `.conflict`. + public var conflict: Components.Responses.Failure { + get throws { + switch self { + case let .conflict(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "conflict", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/412`. + /// + /// HTTP response code: `412 preconditionFailed`. + case preconditionFailed(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.preconditionFailed`. + /// + /// - Throws: An error if `self` is not `.preconditionFailed`. + /// - SeeAlso: `.preconditionFailed`. + public var preconditionFailed: Components.Responses.Failure { + get throws { + switch self { + case let .preconditionFailed(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "preconditionFailed", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/413`. + /// + /// HTTP response code: `413 contentTooLarge`. + case contentTooLarge(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.contentTooLarge`. + /// + /// - Throws: An error if `self` is not `.contentTooLarge`. + /// - SeeAlso: `.contentTooLarge`. + public var contentTooLarge: Components.Responses.Failure { + get throws { + switch self { + case let .contentTooLarge(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "contentTooLarge", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/429`. + /// + /// HTTP response code: `429 tooManyRequests`. + case tooManyRequests(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.tooManyRequests`. + /// + /// - Throws: An error if `self` is not `.tooManyRequests`. + /// - SeeAlso: `.tooManyRequests`. + public var tooManyRequests: Components.Responses.Failure { + get throws { + switch self { + case let .tooManyRequests(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "tooManyRequests", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/421`. + /// + /// HTTP response code: `421 misdirectedRequest`. + case misdirectedRequest(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.misdirectedRequest`. + /// + /// - Throws: An error if `self` is not `.misdirectedRequest`. + /// - SeeAlso: `.misdirectedRequest`. + public var misdirectedRequest: Components.Responses.Failure { + get throws { + switch self { + case let .misdirectedRequest(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "misdirectedRequest", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/500`. + /// + /// HTTP response code: `500 internalServerError`. + case internalServerError(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.internalServerError`. + /// + /// - Throws: An error if `self` is not `.internalServerError`. + /// - SeeAlso: `.internalServerError`. + public var internalServerError: Components.Responses.Failure { + get throws { + switch self { + case let .internalServerError(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "internalServerError", + response: self + ) + } + } + } + /// Error response shared by all endpoints. The body schema is the same for + /// every 4xx/5xx status code; the HTTP status code itself disambiguates + /// which CloudKit failure occurred. See Apple's CloudKit Web Services + /// Error Codes documentation for the full code → status mapping: + /// - 400 BadRequest (BAD_REQUEST, ATOMIC_ERROR) + /// - 401 Unauthorized (AUTHENTICATION_FAILED) + /// - 403 Forbidden (ACCESS_DENIED) + /// - 404 NotFound (NOT_FOUND, ZONE_NOT_FOUND) + /// - 409 Conflict (CONFLICT, EXISTS) + /// - 412 PreconditionFailed (VALIDATING_REFERENCE_ERROR) + /// - 413 RequestEntityTooLarge (QUOTA_EXCEEDED) + /// - 421 UnprocessableEntity (AUTHENTICATION_REQUIRED) + /// - 429 TooManyRequests (THROTTLED) + /// - 500 InternalServerError (INTERNAL_ERROR) + /// - 503 ServiceUnavailable (TRY_AGAIN_LATER) + /// + /// + /// - Remark: Generated from `#/paths//database/{version}/{container}/{environment}/{database}/records/accept/post(acceptShares)/responses/503`. + /// + /// HTTP response code: `503 serviceUnavailable`. + case serviceUnavailable(Components.Responses.Failure) + /// The associated value of the enum case if `self` is `.serviceUnavailable`. + /// + /// - Throws: An error if `self` is not `.serviceUnavailable`. + /// - SeeAlso: `.serviceUnavailable`. + public var serviceUnavailable: Components.Responses.Failure { + get throws { + switch self { + case let .serviceUnavailable(response): + return response + default: + try throwUnexpectedResponseStatus( + expectedStatus: "serviceUnavailable", + response: self + ) + } + } + } + /// Undocumented response. + /// + /// A response with a code that is not documented in the OpenAPI document. + case undocumented(statusCode: Swift.Int, OpenAPIRuntime.UndocumentedPayload) + } + @frozen public enum AcceptableContentType: AcceptableProtocol { + case json + case other(Swift.String) + public init?(rawValue: Swift.String) { + switch rawValue.lowercased() { + case "application/json": + self = .json + default: + self = .other(rawValue) + } + } + public var rawValue: Swift.String { + switch self { + case let .other(string): + return string + case .json: + return "application/json" + } + } + public static var allCases: [Self] { + [ + .json + ] + } + } + } /// Fetch Record Changes /// /// Get all record changes relative to a sync token diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift new file mode 100644 index 00000000..daa6f055 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift @@ -0,0 +1,112 @@ +// +// CloudKitServiceTests.Sharing.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Sharing { + @Suite( + "CloudKitService acceptShares (records/accept)", + .enabled(if: Platform.isCryptoAvailable) + ) + internal struct Accept { + private typealias Helper = CloudKitServiceTests.Sharing + + @Test("acceptShares reports the caller's resulting participation") + internal func acceptReportsParticipation() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "acceptShares": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1", participantStatus: "ACCEPTED") + ]) + ]) + + let result = try #require( + try await service.acceptShares([ShortGUID(value: "guid-1")]).first + ) + #expect(result.participantStatus == .accepted) + #expect(result.participantPermission == .readWrite) + #expect(result.share?.recordType == "cloudKit.share") + + let bodies = await provider.bodies(for: "acceptShares").compactMap { $0 } + let body = try #require(bodies.first) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let sent = try #require(json?["shortGUIDs"] as? [[String: Any]]) + #expect(sent.map { $0["value"] as? String } == ["guid-1"]) + } + + @Test("acceptShares maps results in request order") + internal func acceptMapsResultsInOrder() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "acceptShares": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1"), + Helper.shortGUIDResult(value: "guid-2"), + ]) + ]) + + let results = try await service.acceptShares([ + ShortGUID(value: "guid-1"), + ShortGUID(value: "guid-2"), + ]) + #expect(results.map(\.shortGUID?.value) == ["guid-1", "guid-2"]) + } + + @Test("acceptShares throws on a top-level BAD_REQUEST") + internal func acceptThrowsOnBadRequest() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "acceptShares": .cloudKitError( + statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: "share already accepted" + ) + ]) + + let error = await #expect(throws: CloudKitError.self) { + _ = try await service.acceptShares([ShortGUID(value: "guid-1")]) + } + guard case .badRequest(let reason) = error else { + Issue.record("Expected .badRequest, got \(String(describing: error))") + return + } + #expect(reason == "share already accepted") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift new file mode 100644 index 00000000..a20f00d0 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift @@ -0,0 +1,158 @@ +// +// CloudKitServiceTests.Sharing+Helpers.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import HTTPTypes +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests { + internal enum Sharing {} +} + +extension CloudKitServiceTests.Sharing { + /// Build a web-auth service whose mock transport answers each operation by + /// ID, alongside the provider so tests can inspect recorded request bodies. + /// + /// `records/resolve` and `records/accept` are user-context routes, so the + /// credentials always carry a web-auth token. + internal static func makeServiceWithProvider( + responsesByOperation: [String: ResponseConfig] + ) throws -> (service: CloudKitService, provider: ResponseProvider) { + let provider = ResponseProvider( + responses: responsesByOperation, + defaultResponse: .success(body: Data("{}".utf8)) + ) + let transport = MockTransport(responseProvider: provider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: transport + ) + return (service, provider) + } + + internal static func makeService( + responsesByOperation: [String: ResponseConfig] + ) throws -> CloudKitService { + try makeServiceWithProvider(responsesByOperation: responsesByOperation).service + } + + // MARK: - JSON builders + + /// A `records/resolve` / `records/accept` 200 body wrapping the given results. + internal static func shortGUIDResponse(results: [[String: Any]]) throws -> ResponseConfig { + try jsonResponse(["results": results]) + } + + /// A fully-populated ShortGUID Result dictionary. + internal static func shortGUIDResult( + value: String, + participantStatus: String = "ACCEPTED", + includeRootRecord: Bool = true + ) -> [String: Any] { + var result: [String: Any] = [ + "shortGUID": ["value": value, "shouldFetchRootRecord": true], + "containerIdentifier": TestConstants.serviceContainerIdentifier, + "databaseScope": "SHARED", + "environment": "development", + "zoneID": ["zoneName": "SharedZone", "ownerName": "_owner"], + "rootRecordName": "root-\(value)", + "share": shareRecord(for: value), + "ownerIdentity": [ + "userRecordName": "_owner", + "nameComponents": ["givenName": "Owner", "familyName": "User"], + ], + "participantPermission": "READ_WRITE", + "participantStatus": participantStatus, + "participantType": "USER", + "webpageURL": "https://www.icloud.com/share/\(value)", + ] + if includeRootRecord { + result["rootRecord"] = [ + "recordName": "root-\(value)", + "recordType": "Note", + "recordChangeTag": "tag-1", + "fields": ["title": ["value": "Shared Note", "type": "STRING"]], + ] + } + return result + } + + /// A `cloudKit.share` record dictionary carrying share response keys. + internal static func shareRecord(for value: String) -> [String: Any] { + [ + "recordName": "share-\(value)", + "recordType": "cloudKit.share", + "recordChangeTag": "share-tag-1", + "fields": [:], + "shortGUID": value, + "publicPermission": "READ_ONLY", + "participants": [ + [ + "permission": "READ_WRITE", + "type": "OWNER", + "acceptanceStatus": "ACCEPTED", + "userIdentity": ["userRecordName": "_owner"], + ] + ], + ] + } + + /// A result carrying a `potentialMatchList` instead of an identified caller. + internal static func ambiguousResult(value: String) -> [String: Any] { + [ + "shortGUID": ["value": value], + "participantStatus": "INVITED", + "potentialMatchList": [ + [ + "participantId": "candidate-1", + "contactInformation": ["emailAddress": "one@example.com"], + ], + [ + "participantId": "candidate-2", + "contactInformation": ["phoneNumber": "+15550100"], + ], + ], + ] + } + + private static func jsonResponse(_ object: [String: Any]) throws -> ResponseConfig { + let body = try JSONSerialization.data(withJSONObject: object) + var headers = HTTPFields() + headers[.contentType] = "application/json" + return ResponseConfig(statusCode: 200, headers: headers, body: body, error: nil) + } +} diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift new file mode 100644 index 00000000..1dfba889 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift @@ -0,0 +1,199 @@ +// +// CloudKitServiceTests.Sharing.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Sharing { + @Suite( + "CloudKitService resolveShares (records/resolve)", + .enabled(if: Platform.isCryptoAvailable) + ) + internal struct Resolve { + private typealias Helper = CloudKitServiceTests.Sharing + + @Test("resolveShares maps every field of a ShortGUID Result") + internal func resolveMapsResultFields() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1") + ]) + ]) + + let results = try await service.resolveShares([ShortGUID(value: "guid-1")]) + + #expect(results.count == 1) + let result = try #require(results.first) + #expect(result.shortGUID?.value == "guid-1") + #expect(result.shortGUID?.shouldFetchRootRecord == true) + #expect(result.containerIdentifier == TestConstants.serviceContainerIdentifier) + #expect(result.databaseScope == .shared) + #expect(result.environment == .development) + #expect(result.zoneID?.zoneName == "SharedZone") + #expect(result.zoneID?.ownerName == "_owner") + #expect(result.rootRecordName == "root-guid-1") + #expect(result.rootRecord?.recordName == "root-guid-1") + #expect(result.rootRecord?.recordType == "Note") + #expect(result.share?.recordType == "cloudKit.share") + #expect(result.ownerIdentity?.userRecordName == .recordName("_owner")) + #expect(result.participantPermission == .readWrite) + #expect(result.participantStatus == .accepted) + #expect(result.participantType == .user) + #expect(result.webpageURL == "https://www.icloud.com/share/guid-1") + #expect(result.potentialMatchList.isEmpty) + } + + @Test("resolveShares sends shortGUIDs in request order") + internal func resolveSendsShortGUIDsInOrder() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1"), + Helper.shortGUIDResult(value: "guid-2"), + ]) + ]) + + let results = try await service.resolveShares([ + ShortGUID( + value: "guid-1", + shouldFetchRootRecord: true, + rootRecordDesiredKeys: ["title"] + ), + ShortGUID(value: "guid-2"), + ]) + + #expect(results.map(\.shortGUID?.value) == ["guid-1", "guid-2"]) + + let bodies = await provider.bodies(for: "resolveShortGUIDs").compactMap { $0 } + let body = try #require(bodies.first) + let json = try JSONSerialization.jsonObject(with: body) as? [String: Any] + let sent = try #require(json?["shortGUIDs"] as? [[String: Any]]) + #expect(sent.count == 2) + #expect(sent[0]["value"] as? String == "guid-1") + #expect(sent[0]["shouldFetchRootRecord"] as? Bool == true) + #expect(sent[0]["rootRecordDesiredKeys"] as? [String] == ["title"]) + #expect(sent[1]["value"] as? String == "guid-2") + // Omitted optionals must not be sent as nulls. + #expect(sent[1]["shouldFetchRootRecord"] == nil) + } + + @Test("resolveShares omits the root record when not requested") + internal func resolveWithoutRootRecord() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1", includeRootRecord: false) + ]) + ]) + + let result = try #require( + try await service.resolveShares([ShortGUID(value: "guid-1")]).first + ) + #expect(result.rootRecord == nil) + // The root record *name* is still reported. + #expect(result.rootRecordName == "root-guid-1") + } + + @Test("resolveShares surfaces a potential match list for an ambiguous caller") + internal func resolveSurfacesPotentialMatches() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + Helper.ambiguousResult(value: "guid-amb") + ]) + ]) + + let result = try #require( + try await service.resolveShares([ShortGUID(value: "guid-amb")]).first + ) + #expect(result.participantStatus == .invited) + #expect(result.potentialMatchList.count == 2) + #expect(result.potentialMatchList.first?.participantId == "candidate-1") + #expect( + result.potentialMatchList.first?.contactInformation?.emailAddress + == "one@example.com" + ) + #expect( + result.potentialMatchList.last?.contactInformation?.phoneNumber == "+15550100" + ) + } + + @Test("resolveShares returns an empty array when results are absent") + internal func resolveHandlesMissingResults() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: []) + ]) + + let results = try await service.resolveShares([ShortGUID(value: "guid-1")]) + #expect(results.isEmpty) + } + + @Test("resolveShares throws on a top-level BAD_REQUEST") + internal func resolveThrowsOnBadRequest() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": .cloudKitError( + statusCode: 400, + serverErrorCode: "BAD_REQUEST", + reason: "invalid shortGUID" + ) + ]) + + let error = await #expect(throws: CloudKitError.self) { + _ = try await service.resolveShares([ShortGUID(value: "nope")]) + } + guard case .badRequest(let reason) = error else { + Issue.record("Expected .badRequest, got \(String(describing: error))") + return + } + #expect(reason == "invalid shortGUID") + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift new file mode 100644 index 00000000..5fef6232 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift @@ -0,0 +1,95 @@ +// +// CloudKitServiceTests.Sharing.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Sharing { + @Suite( + "CloudKitService share-record key mapping", + .enabled(if: Platform.isCryptoAvailable) + ) + internal struct ShareInfoMapping { + private typealias Helper = CloudKitServiceTests.Sharing + + @Test("resolveShares lifts share keys off the cloudKit.share record") + internal func resolveLiftsShareInfo() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + Helper.shortGUIDResult(value: "guid-1") + ]) + ]) + + let result = try #require( + try await service.resolveShares([ShortGUID(value: "guid-1")]).first + ) + let shareInfo = try #require(result.shareInfo) + #expect(shareInfo.shortGUID == "guid-1") + #expect(shareInfo.publicPermission == .readOnly) + #expect(shareInfo.participants.count == 1) + let participant = try #require(shareInfo.participants.first) + #expect(participant.permission == .readWrite) + #expect(participant.type == .owner) + #expect(participant.acceptanceStatus == .accepted) + #expect(participant.userIdentity?.userRecordName == .recordName("_owner")) + } + + @Test("shareInfo is nil for a record carrying no share keys") + internal func shareInfoNilForPlainRecord() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + // A `share` entry that is a plain record dictionary — no share keys. + let service = try Helper.makeService(responsesByOperation: [ + "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ + [ + "shortGUID": ["value": "guid-1"], + "share": [ + "recordName": "share-guid-1", + "recordType": "cloudKit.share", + "fields": [:], + ], + ] + ]) + ]) + + let result = try #require( + try await service.resolveShares([ShortGUID(value: "guid-1")]).first + ) + #expect(result.share?.recordName == "share-guid-1") + #expect(result.shareInfo == nil) + } + } +} diff --git a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift new file mode 100644 index 00000000..fec1e7dd --- /dev/null +++ b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift @@ -0,0 +1,160 @@ +// +// ShareModelTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +@Suite("Sharing Models") +internal struct ShareModelTests { + @Test("ShortGUID round-trips through Codable") + internal func shortGUIDRoundTrips() throws { + let original = ShortGUID( + value: "guid-1", + shouldFetchRootRecord: true, + rootRecordDesiredKeys: ["title", "body"] + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(ShortGUID.self, from: data) + #expect(decoded == original) + } + + @Test("ShortGUID defaults the optional knobs to nil") + internal func shortGUIDDefaults() { + let shortGUID = ShortGUID(value: "guid-1") + #expect(shortGUID.value == "guid-1") + #expect(shortGUID.shouldFetchRootRecord == nil) + #expect(shortGUID.rootRecordDesiredKeys == nil) + } + + @Test( + "SharePermission maps CloudKit's wire values", + arguments: [ + ("NONE", SharePermission.none), + ("READ_ONLY", .readOnly), + ("READ_WRITE", .readWrite), + ("UNKNOWN", .unknown), + ] + ) + internal func sharePermissionRawValues(raw: String, expected: SharePermission) { + #expect(SharePermission(rawValue: raw) == expected) + #expect(expected.rawValue == raw) + } + + @Test( + "ShareParticipantType maps CloudKit's wire values", + arguments: [ + ("OWNER", ShareParticipantType.owner), + ("ADMINISTRATOR", .administrator), + ("USER", .user), + ("PUBLIC_USER", .publicUser), + ("UNKNOWN", .unknown), + ] + ) + internal func participantTypeRawValues(raw: String, expected: ShareParticipantType) { + #expect(ShareParticipantType(rawValue: raw) == expected) + #expect(expected.rawValue == raw) + } + + @Test( + "ShareAcceptanceStatus maps CloudKit's wire values", + arguments: [ + ("INVITED", ShareAcceptanceStatus.invited), + ("ACCEPTED", .accepted), + ("REMOVED", .removed), + ("UNKNOWN", .unknown), + ] + ) + internal func acceptanceStatusRawValues(raw: String, expected: ShareAcceptanceStatus) { + #expect(ShareAcceptanceStatus(rawValue: raw) == expected) + #expect(expected.rawValue == raw) + } + + @Test( + "ShareDatabaseScope maps CloudKit's wire values", + arguments: [ + ("PUBLIC", ShareDatabaseScope.public), + ("PRIVATE", .private), + ("SHARED", .shared), + ] + ) + internal func databaseScopeRawValues(raw: String, expected: ShareDatabaseScope) { + #expect(ShareDatabaseScope(rawValue: raw) == expected) + #expect(expected.rawValue == raw) + } + + @Test("ShareRecordInfo defaults every field when constructed empty") + internal func shareRecordInfoDefaults() { + let info = ShareRecordInfo() + #expect(info.shortGUID == nil) + #expect(info.containerIdentifier == nil) + #expect(info.databaseScope == nil) + #expect(info.environment == nil) + #expect(info.zoneID == nil) + #expect(info.rootRecord == nil) + #expect(info.share == nil) + #expect(info.ownerIdentity == nil) + #expect(info.participantPermission == nil) + #expect(info.participantStatus == nil) + #expect(info.participantType == nil) + #expect(info.webpageURL == nil) + #expect(info.potentialMatchList.isEmpty) + } + + @Test("SharePotentialMatch carries partial contact information") + internal func potentialMatchPartialContact() { + let emailOnly = SharePotentialMatch( + participantId: "c1", + contactInformation: .init(emailAddress: "a@example.com") + ) + #expect(emailOnly.contactInformation?.emailAddress == "a@example.com") + #expect(emailOnly.contactInformation?.phoneNumber == nil) + } + + @Test("ShareInfo defaults every field when constructed empty") + internal func shareInfoDefaults() { + let info = ShareInfo() + #expect(info.shortGUID == nil) + #expect(info.sharedRecordName == nil) + #expect(info.publicPermission == nil) + #expect(info.participants.isEmpty) + #expect(info.owner == nil) + #expect(info.currentUserParticipant == nil) + } + + @Test("ShareParticipant defaults every field to nil") + internal func shareParticipantDefaults() { + let participant = ShareParticipant() + #expect(participant.userIdentity == nil) + #expect(participant.permission == nil) + #expect(participant.type == nil) + #expect(participant.acceptanceStatus == nil) + } +} diff --git a/openapi.yaml b/openapi.yaml index 1a569195..8f21df1b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -234,6 +234,140 @@ paths: '503': $ref: '#/components/responses/Failure' + /database/{version}/{container}/{environment}/{database}/records/resolve: + post: + summary: Fetch Record Information + description: | + Resolve one or more share short GUIDs into information about the shared + records they identify — the root record, the `cloudKit.share` record, + the owner identity, and the caller's participation in each share. + + Routed against the public database with web-auth credentials + (user-context auth): Apple's reference documents the path with a fixed + `public` database scope, and the operation resolves shares on behalf of + the *current* user. + + Documented in Apple's archived CloudKit Web Services Reference + (`FetchingRecordInformation`); absent from the current online docs. + operationId: resolveShortGUIDs + tags: + - Records + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + shortGUIDs: + type: array + description: The short GUIDs identifying the shares to resolve. + items: + $ref: '#/components/schemas/ShortGUID' + required: + - shortGUIDs + responses: + '200': + description: Short GUIDs resolved successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/ShortGUIDResultResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + '403': + $ref: '#/components/responses/Failure' + '404': + $ref: '#/components/responses/Failure' + '409': + $ref: '#/components/responses/Failure' + '412': + $ref: '#/components/responses/Failure' + '413': + $ref: '#/components/responses/Failure' + '429': + $ref: '#/components/responses/Failure' + '421': + $ref: '#/components/responses/Failure' + '500': + $ref: '#/components/responses/Failure' + '503': + $ref: '#/components/responses/Failure' + + /database/{version}/{container}/{environment}/{database}/records/accept: + post: + summary: Accept Share Records + description: | + Accept one or more shares — each identified by a short GUID — on behalf + of the current user. The response mirrors `records/resolve`, reporting + the caller's participation status for each accepted share. + + Routed against the public database with web-auth credentials + (user-context auth): Apple's reference documents the path with a fixed + `public` database scope, and there is no current user to accept on + behalf of without web-auth. + + Documented in Apple's archived CloudKit Web Services Reference + (`AcceptingShareRecords`); absent from the current online docs. + operationId: acceptShares + tags: + - Records + parameters: + - $ref: '#/components/parameters/version' + - $ref: '#/components/parameters/container' + - $ref: '#/components/parameters/environment' + - $ref: '#/components/parameters/database' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + shortGUIDs: + type: array + description: The short GUIDs identifying the shares to accept. + items: + $ref: '#/components/schemas/ShortGUID' + required: + - shortGUIDs + responses: + '200': + description: Shares accepted successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/ShortGUIDResultResponse' + '400': + $ref: '#/components/responses/Failure' + '401': + $ref: '#/components/responses/Failure' + '403': + $ref: '#/components/responses/Failure' + '404': + $ref: '#/components/responses/Failure' + '409': + $ref: '#/components/responses/Failure' + '412': + $ref: '#/components/responses/Failure' + '413': + $ref: '#/components/responses/Failure' + '429': + $ref: '#/components/responses/Failure' + '421': + $ref: '#/components/responses/Failure' + '500': + $ref: '#/components/responses/Failure' + '503': + $ref: '#/components/responses/Failure' + /database/{version}/{container}/{environment}/{database}/records/changes: post: summary: Fetch Record Changes @@ -1111,6 +1245,39 @@ components: description: Record fields with their values (no type metadata) additionalProperties: $ref: '#/components/schemas/FieldValueRequest' + createShortGUID: + type: boolean + description: | + Whether to create a short GUID so this record can be shared. The + response echoes the generated GUID back as `shortGUID`. + forRecord: + $ref: '#/components/schemas/ShareTargetReference' + publicPermission: + type: string + enum: [NONE, READ_ONLY, READ_WRITE, UNKNOWN] + description: | + The public read/write permissions to apply. Set when creating a + `cloudKit.share` record. + participants: + type: array + description: | + The participants to invite. Set when creating a `cloudKit.share` + record. + items: + $ref: '#/components/schemas/ShareParticipant' + + ShareTargetReference: + type: object + description: | + Identifies the record being shared when creating a `cloudKit.share` + record (the `forRecord` key). + properties: + recordName: + type: string + recordChangeTag: + type: string + required: + - recordName RecordResponse: type: object @@ -1137,6 +1304,38 @@ components: deleted: type: boolean description: Whether the record was deleted + shortGUID: + type: string + description: | + The short GUID of a shared record. Present only on records that + have been shared (see `createShortGUID` on the request side). + share: + $ref: '#/components/schemas/ShareReference' + publicPermission: + type: string + enum: [NONE, READ_ONLY, READ_WRITE, UNKNOWN] + description: | + The public read/write permissions of a shared record. Present on + `cloudKit.share` records. + participants: + type: array + description: | + The participants in a shared record. Present on `cloudKit.share` + records. + items: + $ref: '#/components/schemas/ShareParticipant' + owner: + $ref: '#/components/schemas/ShareParticipant' + currentUserParticipant: + $ref: '#/components/schemas/ShareParticipant' + + ShareReference: + type: object + description: | + A reference to the `cloudKit.share` record governing a shared record. + properties: + recordName: + type: string FieldValueRequest: type: object @@ -1647,6 +1846,123 @@ components: items: $ref: '#/components/schemas/UserIdentity' + ShortGUID: + type: object + description: | + A short global identifier for a shared record, used to resolve + (`records/resolve`) and accept (`records/accept`) shares. + properties: + value: + type: string + description: The value of the short global ID. + shouldFetchRootRecord: + type: boolean + description: | + Whether the root record should be fetched alongside the share. + rootRecordDesiredKeys: + type: array + description: | + Field names limiting the data returned in the root record. When + omitted, every field of the root record is returned. + items: + type: string + required: + - value + + ShareParticipant: + type: object + description: A participant in a shared record. + properties: + userIdentity: + $ref: '#/components/schemas/UserIdentity' + permission: + type: string + enum: [NONE, READ_ONLY, READ_WRITE, UNKNOWN] + description: The participant's read and write permissions. + type: + type: string + enum: [OWNER, ADMINISTRATOR, USER, PUBLIC_USER, UNKNOWN] + description: The type of participant. + acceptanceStatus: + type: string + enum: [INVITED, ACCEPTED, REMOVED, UNKNOWN] + description: The status of the participant accepting the shared record. + + ShortGUIDResult: + type: object + description: | + The result of resolving or accepting a single share, as returned by + `records/resolve` and `records/accept`. + properties: + shortGUID: + $ref: '#/components/schemas/ShortGUID' + containerIdentifier: + type: string + description: The container holding the shared record. + databaseScope: + type: string + enum: [PUBLIC, PRIVATE, SHARED] + description: The database scope holding the shared record. + environment: + type: string + enum: [development, production] + description: The container environment holding the shared record. + zoneID: + $ref: '#/components/schemas/ZoneID' + rootRecordName: + type: string + description: The name of the root record that was shared. + rootRecord: + $ref: '#/components/schemas/RecordResponse' + share: + $ref: '#/components/schemas/RecordResponse' + ownerIdentity: + $ref: '#/components/schemas/UserIdentity' + participantPermission: + type: string + enum: [NONE, READ_ONLY, READ_WRITE, UNKNOWN] + description: The caller's read and write permissions on the share. + participantStatus: + type: string + enum: [INVITED, ACCEPTED, REMOVED, UNKNOWN] + description: The caller's acceptance status for the share. + participantType: + type: string + enum: [OWNER, ADMINISTRATOR, USER, PUBLIC_USER, UNKNOWN] + description: The caller's participant type for the share. + webpageURL: + type: string + description: | + The fallback webpage configured in CloudKit Dashboard, used to + direct users somewhere when the operation fails. + potentialMatchList: + type: array + description: | + When the participant is not identifiable, the potential + participants the user can choose from. + items: + type: object + properties: + participantId: + type: string + contactInformation: + type: object + properties: + emailAddress: + type: string + phoneNumber: + type: string + + ShortGUIDResultResponse: + type: object + description: | + The response body for `records/resolve` and `records/accept`. + properties: + results: + type: array + items: + $ref: '#/components/schemas/ShortGUIDResult' + ContactsResponse: type: object properties: From 85d5713f7132baf92f76dbef97b95632d30f3990 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 20 Aug 2026 18:43:50 -0400 Subject: [PATCH 2/5] Add MistDemo resolve/accept coverage for share operations Flip ResolveCommand off PendingStub, add AcceptCommand, wire integration phases and web routes so records/resolve and records/accept can be tested. Co-authored-by: Cursor --- .../MistDemoKit/Commands/AcceptCommand.swift | 124 ++++++++++++++++++ .../MistDemoKit/Commands/ResolveCommand.swift | 82 +++++++++--- .../Commands/TestPrivateCommand.swift | 3 +- .../Commands/TestPublicCommand.swift | 8 +- .../Configuration/AcceptConfig.swift | 109 +++++++++++++++ .../Configuration/ResolveConfig.swift | 70 ++++++++-- .../Configuration/TestPrivateConfig.swift | 12 +- .../Configuration/TestPublicConfig.swift | 11 +- .../MistDemoKit/Errors/AcceptError.swift | 70 ++++++++++ .../MistDemoKit/Errors/ResolveError.swift | 70 ++++++++++ .../Integration/IntegrationTestRunner.swift | 5 +- .../Integration/PhaseContext.swift | 6 + .../Phases/AcceptSharesPhase.swift | 79 +++++++++++ .../Phases/ResolveRecordsPhase.swift | 43 +++++- .../Tests/PrivateDatabaseTest.swift | 10 +- .../Tests/PublicDatabaseTest.swift | 7 +- .../Sources/MistDemoKit/MistDemoRunner.swift | 3 +- .../CloudKitService+WebBackend+Shares.swift | 69 ++++++++++ .../MistDemoKit/Server/WebBackend.swift | 12 ++ .../Server/WebRequests+Shares.swift | 61 +++++++++ .../MistDemoKit/Server/WebResponse.swift | 7 + .../Server/WebServer+Pending.swift | 87 ------------ .../MistDemoKit/Server/WebServer+Shares.swift | 101 ++++++++++++++ .../MistDemoKit/Server/WebServer.swift | 2 +- .../MistDemoKit/Utilities/PendingStub.swift | 66 ---------- .../Configuration/AcceptConfigTests.swift | 53 ++++++++ .../Configuration/ResolveConfigTests.swift | 61 +++++++++ .../Server/MockBackend+Calls.swift | 8 ++ .../Server/MockBackend+ShareOperations.swift | 78 +++++++++++ .../MistDemoTests/Server/MockBackend.swift | 2 + 30 files changed, 1115 insertions(+), 204 deletions(-) create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Errors/AcceptError.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Errors/ResolveError.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Shares.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Shares.swift delete mode 100644 Examples/MistDemo/Sources/MistDemoKit/Utilities/PendingStub.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/AcceptConfigTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Configuration/ResolveConfigTests.swift create mode 100644 Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift new file mode 100644 index 00000000..57790233 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift @@ -0,0 +1,124 @@ +// +// AcceptCommand.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Command for `records/accept`. Accepts shares identified by short GUID on +/// behalf of the current user, adding the caller as a participant. +public struct AcceptCommand: MistDemoCommand, OutputFormatting { + /// The configuration type. + public typealias Config = AcceptConfig + /// The command name. + public static let commandName = "accept" + /// The command abstract. + public static let abstract = "Accept shares by short GUID (records/accept)" + /// The command help text. + public static let helpText = """ + ACCEPT - Accept shares by short GUID + + USAGE: + mistdemo accept --short-guid [,...] [options] + mistdemo accept --share-url [,...] [options] + + INPUT (choose one): + --short-guid Comma-separated short GUIDs + --share-url Comma-separated share URLs — the short + GUID is parsed from each URL's last path + component (e.g. .../share/abc123 → abc123) + + OPTIONS: + --fetch-root-record Ask CloudKit to include the root record + --fields Restrict the root record's fields + --output-format Output format (json, table, csv, yaml) + + EXAMPLES: + mistdemo accept --short-guid abc123 + mistdemo accept --share-url https://www.icloud.com/share/abc123 + + NOTES: + Requires API + web-auth credentials — CloudKit pins records/accept + to the public database with web-auth regardless of --database. + Accepting an already-accepted or invalid short GUID fails the + entire request. + """ + + private let config: AcceptConfig + + /// Creates a new instance. + public init(config: AcceptConfig) { + self.config = config + } + + /// Executes the command. + public func execute() async throws { + guard config.base.hasUserContextCredentials else { + throw AcceptError.webAuthRequired + } + + let service = try MistKitClientFactory.create(for: config.base) + let shortGUIDs = config.shortGUIDs.map { + ShortGUID( + value: $0, + shouldFetchRootRecord: config.fetchRootRecord, + rootRecordDesiredKeys: config.fields + ) + } + + do { + let results = try await service.acceptShares(shortGUIDs) + printSummary(results) + try await outputResults(results, format: config.output) + } catch let error as AcceptError { + throw error + } catch { + throw AcceptError.operationFailed(error.localizedDescription) + } + } + + private func printSummary(_ results: [ShareRecordInfo]) { + print( + "✅ Accepted \(results.count) share\(results.count == 1 ? "" : "s")" + ) + for result in results { + print(" - shortGUID: \(result.shortGUID?.value ?? "-")") + print(" rootRecordName: \(result.rootRecordName ?? "-")") + print( + " participantStatus: \(result.participantStatus?.rawValue ?? "-")" + ) + print( + " participantPermission: " + + "\(result.participantPermission?.rawValue ?? "-")" + ) + if let zoneID = result.zoneID { + print(" zoneID: \(zoneID.zoneName)") + } + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift index 60fc4bea..ec5f2df0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift @@ -28,36 +28,45 @@ // internal import Foundation +internal import MistKit -/// Stub command for `records/resolve`. Resolves a share URL or record -/// reference to a CloudKit record. The MistKit Swift wrapper is tracked -/// in #41; until that lands, this command prints the standard pending -/// banner and exits 0 so the `--help` shape is discoverable today. -public struct ResolveCommand: MistDemoCommand { +/// Command for `records/resolve`. Resolves share short GUIDs — the handle +/// carried by CloudKit share URLs — into the root record, the governing +/// `cloudKit.share` record, and the caller's participation in each share. +public struct ResolveCommand: MistDemoCommand, OutputFormatting { /// The configuration type. public typealias Config = ResolveConfig /// The command name. public static let commandName = "resolve" /// The command abstract. - public static let abstract = "Resolve a share URL or record reference (pending #41)" + public static let abstract = "Resolve share short GUIDs (records/resolve)" /// The command help text. public static let helpText = """ - RESOLVE - Resolve a share URL or record reference + RESOLVE - Resolve share short GUIDs USAGE: - mistdemo resolve --share-url [options] - mistdemo resolve --record-name [options] + mistdemo resolve --short-guid [,...] [options] + mistdemo resolve --share-url [,...] [options] INPUT (choose one): - --share-url Share URL to resolve - --record-name Record name to resolve + --short-guid Comma-separated short GUIDs + --share-url Comma-separated share URLs — the short + GUID is parsed from each URL's last path + component (e.g. .../share/abc123 → abc123) OPTIONS: - --database Database to target - --output-format Output format (json, table, csv, yaml) + --fetch-root-record Ask CloudKit to include the root record + --fields Restrict the root record's fields + --output-format Output format (json, table, csv, yaml) - STATUS: - Not yet implemented — pending MistKit support, tracked in #41. + EXAMPLES: + mistdemo resolve --short-guid abc123 + mistdemo resolve --short-guid abc123,def456 --fetch-root-record + mistdemo resolve --share-url https://www.icloud.com/share/abc123 + + NOTES: + Requires API + web-auth credentials — CloudKit pins records/resolve + to the public database with web-auth regardless of --database. """ private let config: ResolveConfig @@ -69,6 +78,47 @@ public struct ResolveCommand: MistDemoCommand { /// Executes the command. public func execute() async throws { - PendingStub.printPending(endpoint: "records/resolve", trackingIssue: 41) + guard config.base.hasUserContextCredentials else { + throw ResolveError.webAuthRequired + } + + let service = try MistKitClientFactory.create(for: config.base) + let shortGUIDs = config.shortGUIDs.map { + ShortGUID( + value: $0, + shouldFetchRootRecord: config.fetchRootRecord, + rootRecordDesiredKeys: config.fields + ) + } + + do { + let results = try await service.resolveShares(shortGUIDs) + printSummary(results) + try await outputResults(results, format: config.output) + } catch let error as ResolveError { + throw error + } catch { + throw ResolveError.operationFailed(error.localizedDescription) + } + } + + private func printSummary(_ results: [ShareRecordInfo]) { + print( + "✅ Resolved \(results.count) share\(results.count == 1 ? "" : "s")" + ) + for result in results { + print(" - shortGUID: \(result.shortGUID?.value ?? "-")") + print(" rootRecordName: \(result.rootRecordName ?? "-")") + print( + " participantStatus: \(result.participantStatus?.rawValue ?? "-")" + ) + print( + " participantPermission: " + + "\(result.participantPermission?.rawValue ?? "-")" + ) + if let zoneID = result.zoneID { + print(" zoneID: \(zoneID.zoneName)") + } + } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index a86ad306..f069a43c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -99,7 +99,8 @@ public struct TestPrivateCommand: MistDemoCommand { assetSizeKB: config.assetSizeKB, skipCleanup: config.skipCleanup, verbose: config.verbose, - lookupEmail: config.lookupEmail + lookupEmail: config.lookupEmail, + shareShortGUID: config.shareShortGUID ) try await runner.runPrivateWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift index 0aa801e7..8f8509e8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -60,11 +60,16 @@ public struct TestPublicCommand: MistDemoCommand { Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). Must belong to an iCloud account discoverable to the caller, otherwise the phase skips. + --share-short-guid + Short GUID for records/resolve + records/accept phases + (CLOUDKIT_SHARE_SHORT_GUID). Must identify an existing share, + otherwise both phases skip. EXAMPLES: mistdemo test-public --verbose mistdemo test-public --skip-cleanup --verbose mistdemo test-public --lookup-email me@example.com + mistdemo test-public --share-short-guid abc123 NOTES: - Requires CLOUDKIT_KEY_ID and CLOUDKIT_PRIVATE_KEY @@ -99,7 +104,8 @@ public struct TestPublicCommand: MistDemoCommand { assetSizeKB: config.assetSizeKB, skipCleanup: config.skipCleanup, verbose: config.verbose, - lookupEmail: config.lookupEmail + lookupEmail: config.lookupEmail, + shareShortGUID: config.shareShortGUID ) try await runner.runBasicWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift new file mode 100644 index 00000000..e2ef62fa --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift @@ -0,0 +1,109 @@ +// +// AcceptConfig.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +internal import Foundation + +/// Configuration for the `accept` command (`records/accept`). +public struct AcceptConfig: Sendable, ConfigurationParseable { + /// The configuration reader type. + public typealias ConfigReader = MistDemoConfiguration + /// The base configuration type. + public typealias BaseConfig = MistDemoConfig + + /// The base MistDemo configuration. + public let base: MistDemoConfig + /// The short GUIDs to accept, in request order. + public let shortGUIDs: [String] + /// Whether to ask CloudKit to include the root record alongside each + /// accepted share. When `nil`, CloudKit applies its own default. + public let fetchRootRecord: Bool? + /// Field names limiting the root record payload, when fetched. + public let fields: [String]? + /// The output format. + public let output: OutputFormat + + /// Creates a new instance. + public init( + base: MistDemoConfig, + shortGUIDs: [String], + fetchRootRecord: Bool? = nil, + fields: [String]? = nil, + output: OutputFormat = .json + ) { + self.base = base + self.shortGUIDs = shortGUIDs + self.fetchRootRecord = fetchRootRecord + self.fields = fields + self.output = output + } + + /// Parse configuration from command line arguments. + public init( + configuration: MistDemoConfiguration, + base: MistDemoConfig? + ) async throws { + let baseConfig: MistDemoConfig + if let base { + baseConfig = base + } else { + baseConfig = try await MistDemoConfig( + configuration: configuration, + base: nil + ) + } + + let outputString = + configuration.string( + forKey: MistDemoConstants.ConfigKeys.outputFormat, + default: "json" + ) ?? "json" + let output = OutputFormat(rawValue: outputString) ?? .json + + let fetchRootRecord = configuration.optionalBool( + forKey: "fetch.root.record" + ) + let fields = configuration.commaSeparatedList( + forKey: MistDemoConstants.ConfigKeys.fields + ) + + let shortGUIDs = ResolveConfig.parseShortGUIDs(from: configuration) + guard !shortGUIDs.isEmpty else { + throw AcceptError.shortGUIDRequired + } + + self.init( + base: baseConfig, + shortGUIDs: shortGUIDs, + fetchRootRecord: fetchRootRecord, + fields: fields, + output: output + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift index 3bacfe31..fdae5da7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift @@ -30,10 +30,7 @@ public import ConfigKeyKit internal import Foundation -/// Configuration for the `resolve` command. Parses the future argument shape -/// even though `ResolveCommand.execute()` is currently a `PendingStub` -/// — the `--help` text and argument names stabilize here so callers can -/// integrate against the real surface when #41 lands. +/// Configuration for the `resolve` command (`records/resolve`). public struct ResolveConfig: Sendable, ConfigurationParseable { /// The configuration reader type. public typealias ConfigReader = MistDemoConfiguration @@ -42,23 +39,28 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { /// The base MistDemo configuration. public let base: MistDemoConfig - /// Optional share URL to resolve. - public let shareURL: String? - /// Optional record name to resolve. - public let recordName: String? + /// The short GUIDs to resolve, in request order. + public let shortGUIDs: [String] + /// Whether to ask CloudKit to include the root record alongside each + /// share. When `nil`, CloudKit applies its own default. + public let fetchRootRecord: Bool? + /// Field names limiting the root record payload, when fetched. + public let fields: [String]? /// The output format. public let output: OutputFormat /// Creates a new instance. public init( base: MistDemoConfig, - shareURL: String? = nil, - recordName: String? = nil, + shortGUIDs: [String], + fetchRootRecord: Bool? = nil, + fields: [String]? = nil, output: OutputFormat = .json ) { self.base = base - self.shareURL = shareURL - self.recordName = recordName + self.shortGUIDs = shortGUIDs + self.fetchRootRecord = fetchRootRecord + self.fields = fields self.output = output } @@ -84,11 +86,51 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { ) ?? "json" let output = OutputFormat(rawValue: outputString) ?? .json + let fetchRootRecord = configuration.optionalBool( + forKey: "fetch.root.record" + ) + let fields = configuration.commaSeparatedList( + forKey: MistDemoConstants.ConfigKeys.fields + ) + + let shortGUIDs = Self.parseShortGUIDs(from: configuration) + guard !shortGUIDs.isEmpty else { + throw ResolveError.shortGUIDRequired + } + self.init( base: baseConfig, - shareURL: configuration.string(forKey: "share-url"), - recordName: configuration.string(forKey: "record-name"), + shortGUIDs: shortGUIDs, + fetchRootRecord: fetchRootRecord, + fields: fields, output: output ) } + + /// Parse short GUIDs from `--short-guid` (preferred, comma-separated) or + /// `--share-url` (comma-separated share URLs, each reduced to its last + /// path component — the short GUID value CloudKit's share webpage URLs + /// carry, e.g. `https://www.icloud.com/share/abc123` → `abc123`). + internal static func parseShortGUIDs( + from configuration: MistDemoConfiguration + ) -> [String] { + if let fromGUIDs = configuration.commaSeparatedList(forKey: "short.guid"), + !fromGUIDs.isEmpty + { + return fromGUIDs + } + + guard let shareURLs = configuration.commaSeparatedList(forKey: "share.url") + else { + return [] + } + return shareURLs.compactMap(parseShortGUID(fromShareURL:)) + } + + /// Extract the short GUID value from a share URL's last path component. + internal static func parseShortGUID(fromShareURL shareURL: String) -> String? { + guard let url = URL(string: shareURL) else { return nil } + let value = url.lastPathComponent + return value.isEmpty ? nil : value + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index 9b03c3c2..381cb254 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -50,6 +50,10 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { /// Optional email used by the lookup-users-by-email phase. Must belong to /// an iCloud account discoverable to the caller; otherwise the phase skips. public let lookupEmail: String? + /// Optional share short GUID used by the resolve/accept sharing phases. + /// Unused by `PrivateDatabaseTest` today (those phases are public-DB-only) + /// but kept for symmetry with `TestPublicConfig`. + public let shareShortGUID: String? /// Creates a new instance. public init( @@ -58,7 +62,8 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { assetSizeKB: Int = 100, skipCleanup: Bool = false, verbose: Bool = false, - lookupEmail: String? = nil + lookupEmail: String? = nil, + shareShortGUID: String? = nil ) { self.base = base self.recordCount = recordCount @@ -66,6 +71,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { self.skipCleanup = skipCleanup self.verbose = verbose self.lookupEmail = lookupEmail + self.shareShortGUID = shareShortGUID } /// Parse configuration from command line arguments. @@ -106,6 +112,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { let verbose = configuration.bool(forKey: "verbose", default: false) let lookupEmail = configuration.string(forKey: "lookup.email") + let shareShortGUID = configuration.string(forKey: "share.short.guid") self.init( base: baseConfig, @@ -113,7 +120,8 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, verbose: verbose, - lookupEmail: lookupEmail + lookupEmail: lookupEmail, + shareShortGUID: shareShortGUID ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift index 86b663c5..3c8ebb85 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift @@ -49,6 +49,9 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { /// Optional email used by the lookup-users-by-email phase. Must belong to /// an iCloud account discoverable to the caller; otherwise the phase skips. public let lookupEmail: String? + /// Optional share short GUID used by the resolve/accept sharing phases. + /// Must identify an existing share; otherwise both phases skip. + public let shareShortGUID: String? /// Creates a new instance. public init( @@ -57,7 +60,8 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { assetSizeKB: Int = 100, skipCleanup: Bool = false, verbose: Bool = false, - lookupEmail: String? = nil + lookupEmail: String? = nil, + shareShortGUID: String? = nil ) { self.base = base self.recordCount = recordCount @@ -65,6 +69,7 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { self.skipCleanup = skipCleanup self.verbose = verbose self.lookupEmail = lookupEmail + self.shareShortGUID = shareShortGUID } /// Parse configuration from command line arguments. @@ -91,6 +96,7 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { let verbose = configuration.bool(forKey: "verbose", default: false) let lookupEmail = configuration.string(forKey: "lookup.email") + let shareShortGUID = configuration.string(forKey: "share.short.guid") self.init( base: baseConfig, @@ -98,7 +104,8 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, verbose: verbose, - lookupEmail: lookupEmail + lookupEmail: lookupEmail, + shareShortGUID: shareShortGUID ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/AcceptError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/AcceptError.swift new file mode 100644 index 00000000..af6e96dc --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/AcceptError.swift @@ -0,0 +1,70 @@ +// +// AcceptError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during accept command execution. +public enum AcceptError: Error, LocalizedError { + case shortGUIDRequired + case webAuthRequired + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .shortGUIDRequired: + return + "No short GUID provided. Use --short-guid or " + + "--share-url ." + case .webAuthRequired: + return + "accept requires API + web-auth credentials. Set " + + "CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN, or run " + + "`mistdemo auth-token` first." + case .operationFailed(let reason): + return "Accept operation failed: \(reason)" + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .shortGUIDRequired: + return + "Pass --short-guid abc123, or --share-url " + + "https://www.icloud.com/share/abc123." + case .webAuthRequired: + return + "records/accept is pinned to CloudKit's public DB and " + + "requires web-auth." + case .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Errors/ResolveError.swift b/Examples/MistDemo/Sources/MistDemoKit/Errors/ResolveError.swift new file mode 100644 index 00000000..6429ab9f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Errors/ResolveError.swift @@ -0,0 +1,70 @@ +// +// ResolveError.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// Errors that can occur during resolve command execution. +public enum ResolveError: Error, LocalizedError { + case shortGUIDRequired + case webAuthRequired + case operationFailed(String) + + /// A localized description of the error. + public var errorDescription: String? { + switch self { + case .shortGUIDRequired: + return + "No short GUID provided. Use --short-guid or " + + "--share-url ." + case .webAuthRequired: + return + "resolve requires API + web-auth credentials. Set " + + "CLOUDKIT_API_TOKEN and CLOUDKIT_WEB_AUTH_TOKEN, or run " + + "`mistdemo auth-token` first." + case .operationFailed(let reason): + return "Resolve operation failed: \(reason)" + } + } + + /// A localized recovery suggestion. + public var recoverySuggestion: String? { + switch self { + case .shortGUIDRequired: + return + "Pass --short-guid abc123, or --share-url " + + "https://www.icloud.com/share/abc123." + case .webAuthRequired: + return + "records/resolve is pinned to CloudKit's public DB and " + + "requires web-auth." + case .operationFailed: + return nil + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index f0a60bbc..3c3170d8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -47,6 +47,8 @@ internal struct IntegrationTestRunner { internal let verbose: Bool /// Optional email forwarded to `PhaseContext.lookupEmail`. internal let lookupEmail: String? + /// Optional share short GUID forwarded to `PhaseContext.shareShortGUID`. + internal let shareShortGUID: String? /// Run the public-database workflow. internal func runBasicWorkflow() async throws { @@ -71,7 +73,8 @@ internal struct IntegrationTestRunner { assetSizeKB: assetSizeKB, skipCleanup: skipCleanup, verbose: verbose, - lookupEmail: lookupEmail + lookupEmail: lookupEmail, + shareShortGUID: shareShortGUID ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index 7bf738f3..0b19a1ef 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -44,4 +44,10 @@ internal struct PhaseContext: Sendable { /// nil, the phase falls back to the caller's own email (often unavailable) /// and skips otherwise. internal let lookupEmail: String? + /// Optional share short GUID used by `ResolveRecordsPhase` and + /// `AcceptSharesPhase` to exercise `records/resolve` / `records/accept` + /// against a known share. There is no way to mint a short GUID from + /// within this pipeline — it must come from a share created out of band + /// — so both phases skip when this is `nil`. + internal let shareShortGUID: String? } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift new file mode 100644 index 00000000..5b695b97 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift @@ -0,0 +1,79 @@ +// +// AcceptSharesPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Calls POST `records/accept`. +/// +/// Like `ResolveRecordsPhase`, prefers the short GUID supplied via +/// `PhaseContext.shareShortGUID` (`--share-short-guid` / +/// `CLOUDKIT_SHARE_SHORT_GUID`) and skips (non-fatally) when it isn't +/// configured. Accepting an already-accepted share fails the request, so +/// this phase is only useful against a freshly-invited fixture share. +internal struct AcceptSharesPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Accept shares" + internal static let emoji = "🤝" + internal static let apiName = "acceptShares" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let shortGUID = context.shareShortGUID, !shortGUID.isEmpty else { + print( + """ + ⏭️ Skipping — no share short GUID available. Set \ + --share-short-guid or CLOUDKIT_SHARE_SHORT_GUID to exercise \ + this phase. + """ + ) + return NoState() + } + + let results = try await context.service.acceptShares([ + ShortGUID(value: shortGUID) + ]) + + print( + "✅ Accepted \(results.count) share\(results.count == 1 ? "" : "s")" + ) + + if context.verbose { + for result in results { + print(" Root record: \(result.rootRecordName ?? "-")") + print(" Participant status: \(result.participantStatus?.rawValue ?? "-")") + } + } + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift index 7f66dc21..0c56b379 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift @@ -28,21 +28,52 @@ // internal import Foundation +internal import MistKit -/// Stub phase for `records/resolve`. The pipeline does not wire this phase -/// into `PublicDatabaseTest` / `PrivateDatabaseTest` yet — it stays available -/// for `#41` to flip into a real run when the MistKit Swift wrapper lands. +/// Calls POST `records/resolve`. +/// +/// Prefers the short GUID supplied via `PhaseContext.shareShortGUID` +/// (`--share-short-guid` / `CLOUDKIT_SHARE_SHORT_GUID`) since resolving a +/// share requires a short GUID that already exists — one isn't produced by +/// any other phase in this pipeline. Skips (non-fatally) when no fixture +/// short GUID is configured. internal struct ResolveRecordsPhase: IntegrationPhase { internal typealias Input = NoState internal typealias Output = NoState - internal static let title = "Resolve records (pending #41)" + internal static let title = "Resolve shares" internal static let emoji = "🔗" - internal static let apiName = "resolveRecords" + internal static let apiName = "resolveShares" internal func run(input: NoState, context: PhaseContext) async throws -> NoState { print("\n\(Self.emoji) \(Self.title)") - PendingStub.printPending(endpoint: "records/resolve", trackingIssue: 41) + + guard let shortGUID = context.shareShortGUID, !shortGUID.isEmpty else { + print( + """ + ⏭️ Skipping — no share short GUID available. Set \ + --share-short-guid or CLOUDKIT_SHARE_SHORT_GUID to exercise \ + this phase. + """ + ) + return NoState() + } + + let results = try await context.service.resolveShares([ + ShortGUID(value: shortGUID) + ]) + + print( + "✅ Resolved \(results.count) share\(results.count == 1 ? "" : "s")" + ) + + if context.verbose { + for result in results { + print(" Root record: \(result.rootRecordName ?? "-")") + print(" Participant status: \(result.participantStatus?.rawValue ?? "-")") + } + } + return NoState() } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index d1823e77..55468c82 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -35,10 +35,12 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { internal let database: MistKit.Database = .private // User-identity phases (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, - // `users/lookup/*`) are intentionally absent: CloudKit Web Services rejects - // these endpoints on the private database with "endpoint not applicable in - // the database type 'privatedb'". They only belong in the public-database - // pipeline; the service resolves web-auth credentials per call when needed. + // `users/lookup/*`) and the sharing phases (`ResolveRecordsPhase`, + // `AcceptSharesPhase`) are intentionally absent: CloudKit Web Services + // rejects these endpoints on the private database with "endpoint not + // applicable in the database type 'privatedb'". They only belong in the + // public-database pipeline; the service resolves web-auth credentials per + // call when needed. internal let phases: [any IntegrationPhase] = [ ListZonesPhase(), ModifyZonesPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index d3ded354..850c9d38 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -38,8 +38,9 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { /// - Parameters: /// - database: must be `.public`. Defaults to `.public`. /// - includeUserContextPhases: when `true`, appends user-identity phases - /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`). - /// Those phases need web-auth credentials, which the resolver picks per + /// (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, `users/lookup/*`) + /// plus the sharing phases (`ResolveRecordsPhase`, `AcceptSharesPhase`). + /// All of these need web-auth credentials, which the resolver picks per /// call from the service's `Credentials`. The runner sets this based on /// whether web-auth credentials are configured. internal init( @@ -70,6 +71,8 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { phases.append(DiscoverUserIdentitiesPhase()) phases.append(LookupUsersByEmailPhase()) phases.append(LookupUsersByRecordNamePhase()) + phases.append(ResolveRecordsPhase()) + phases.append(AcceptSharesPhase()) } self.phases = phases } diff --git a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift index 2817dc15..1bedc3d7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/MistDemoRunner.swift @@ -66,10 +66,11 @@ public enum MistDemoRunner { await registry.register(TestPublicCommand.self) await registry.register(TestPrivateCommand.self) await registry.register(DemoErrorsCommand.self) + await registry.register(ResolveCommand.self) + await registry.register(AcceptCommand.self) // Pending MistKit wrappers — print "pending #N" and exit 0. Each // command flips to a real implementation when its tracking issue lands. - await registry.register(ResolveCommand.self) await registry.register(RereferenceAssetCommand.self) await registry.register(ListSubscriptionsCommand.self) await registry.register(LookupSubscriptionCommand.self) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift new file mode 100644 index 00000000..29c6d9cb --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift @@ -0,0 +1,69 @@ +// +// CloudKitService+WebBackend+Shares.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +// Sharing `WebBackend` conformance. Like the user-identity routes, these +// operate on the public database with web-auth credentials, so neither +// takes a `database` argument. The primary conformance declaration lives in +// `CloudKitService+WebBackend.swift`. +extension CloudKitService { + internal func webResolveShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] { + try await resolveShares( + shortGUIDs.map { + ShortGUID( + value: $0, + shouldFetchRootRecord: fetchRootRecord, + rootRecordDesiredKeys: fields + ) + } + ) + } + + internal func webAcceptShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] { + try await acceptShares( + shortGUIDs.map { + ShortGUID( + value: $0, + shouldFetchRootRecord: fetchRootRecord, + rootRecordDesiredKeys: fields + ) + } + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index 2669072b..4057ba7c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -148,6 +148,18 @@ internal protocol WebBackend: Sendable { recordName: String?, database: MistKit.Database ) async throws -> AssetUploadReceipt + + func webResolveShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] + + func webAcceptShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] } // The `CloudKitService: WebBackend` conformance lives in diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Shares.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Shares.swift new file mode 100644 index 00000000..fe07e12d --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Shares.swift @@ -0,0 +1,61 @@ +// +// WebRequests+Shares.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +// Sharing routes have no `database` field: `resolveShares` / `acceptShares` +// operate on the public database with web-auth credentials regardless of +// the request's selected database. +extension WebRequests { + /// `POST /api/records/resolve` and `POST /api/records/accept` — resolve or + /// accept shares identified by short GUID. + internal struct ResolveOrAcceptShares: Decodable { + private enum CodingKeys: String, CodingKey { + case shortGUIDs + case fetchRootRecord + case fields + } + + internal let shortGUIDs: [String] + internal let fetchRootRecord: Bool? + internal let fields: [String]? + + internal init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.shortGUIDs = + try container.decodeIfPresent([String].self, forKey: .shortGUIDs) ?? [] + self.fetchRootRecord = try container.decodeIfPresent( + Bool.self, forKey: .fetchRootRecord + ) + self.fields = try container.decodeIfPresent( + [String].self, forKey: .fields + ) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift index d75a0782..2f5fcb03 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebResponse.swift @@ -117,6 +117,13 @@ internal enum WebResponse { internal let registered: Bool } + /// Body returned by `records/resolve` and `records/accept`. `ShareRecordInfo` + /// already encodes to the wire shape the browser panel wants, so this is a + /// thin wrapper. + internal struct Shares: Encodable { + internal let results: [ShareRecordInfo] + } + /// Body returned for any handled CloudKit/MistKit error so the UI can /// surface the message without parsing transport-level failures. internal struct Error: Encodable { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift deleted file mode 100644 index 399f5fa2..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Pending.swift +++ /dev/null @@ -1,87 +0,0 @@ -// -// WebServer+Pending.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -#if canImport(Hummingbird) - internal import Foundation - internal import HTTPTypes - internal import Hummingbird - - extension WebServer { - private static func registerPendingPost( - api: RouterGroup, - path: String, - endpoint: String, - trackingIssue: Int - ) { - let bytes: Data - do { - bytes = try PendingStub.responseJSON( - endpoint: endpoint, trackingIssue: trackingIssue - ) - } catch { - // PendingStub.responseJSON encodes a fixed shape; failure here would - // be a programmer error, not a runtime failure. Crash early so a - // broken stub doesn't masquerade as a working route. - preconditionFailure( - "Failed to encode pending-stub body for \(endpoint): \(error)" - ) - } - api.post(RouterPath(path)) { _, _ -> Response in - Self.jsonResponse(status: .notImplemented, bytes: bytes) - } - } - - /// Register 501 stubs for every CloudKit Web Services endpoint not yet - /// wired to a real handler. Each route returns the shared - /// `PendingStub.responseJSON` payload so the browser-side panel renders a - /// structured "pending #N" body. When a route is ready, flip the - /// corresponding `api.(...)` registration here to the real handler - /// (or move it into a dedicated extension file). - /// - /// Only one endpoint remains pending — it has **no MistKit wrapper yet**: - /// - `POST records/resolve` (#41) — `CloudKitService` has no - /// `resolveRecords`; `ResolveCommand` likewise only prints a stub. - /// - /// Already moved off this list to real handlers: `subscriptions/*` - /// (#49/#50/#51 → `WebServer+Subscriptions`), `tokens/*` - /// (#52/#53 → `WebServer+Tokens`), `assets/rereference` - /// (#31 → `WebServer+Assets`), and the records/zones/users endpoints - /// (#394 → `WebServer+Records` / `WebServer+Zones` / `WebServer+Users`). - internal func addPendingEndpoints( - api: RouterGroup - ) { - Self.registerPendingPost( - api: api, - path: "records/resolve", - endpoint: "records/resolve", - trackingIssue: 41 - ) - } - } -#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Shares.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Shares.swift new file mode 100644 index 00000000..446c0678 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Shares.swift @@ -0,0 +1,101 @@ +// +// WebServer+Shares.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import Hummingbird + internal import MistKit + + extension WebServer { + /// `POST /api/records/resolve` and `POST /api/records/accept` — resolve + /// or accept shares identified by short GUID. Both operate on the + /// public database with web-auth credentials, so neither carries a + /// `database` selector. + internal func addSharesEndpoints( + api: RouterGroup + ) { + addRecordsResolveEndpoint(api: api) + addRecordsAcceptEndpoint(api: api) + } + + /// `POST /api/records/resolve`. + private func addRecordsResolveEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/resolve") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.ResolveOrAcceptShares.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let results = try await backend.webResolveShares( + shortGUIDs: body.shortGUIDs, + fetchRootRecord: body.fetchRootRecord, + fields: body.fields + ) + return try WebJSON.encoder().encode( + WebResponse.Shares(results: results) + ) + } + } + } + + /// `POST /api/records/accept`. + private func addRecordsAcceptEndpoint( + api: RouterGroup + ) { + let tokenStore = self.tokenStore + let backendFactory = self.backendFactory + api.post("records/accept") { request, context -> Response in + guard let token = await tokenStore.currentToken else { + return Response(status: .unauthorized) + } + let body = try await request.decode( + as: WebRequests.ResolveOrAcceptShares.self, context: context + ) + return try await Self.runOperation { () -> Data in + let backend = try backendFactory.make(token) + let results = try await backend.webAcceptShares( + shortGUIDs: body.shortGUIDs, + fetchRootRecord: body.fetchRootRecord, + fields: body.fields + ) + return try WebJSON.encoder().encode( + WebResponse.Shares(results: results) + ) + } + } + } + } +#endif diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift index 686ff9ec..d09f721e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -133,7 +133,7 @@ addSubscriptionEndpoints(api: api) addTokenEndpoints(api: api) addAssetEndpoints(api: api) - addPendingEndpoints(api: api) + addSharesEndpoints(api: api) return router } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/PendingStub.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/PendingStub.swift deleted file mode 100644 index 3f336e85..00000000 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/PendingStub.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// PendingStub.swift -// MistDemo -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation - -/// Shared messaging for CloudKit Web Services endpoints whose MistKit Swift -/// wrapper hasn't landed yet. Used by CLI commands, integration phases, and -/// `mistdemo web` 501 responses so the "pending #N" banner stays consistent -/// and grep-able. When the underlying MistKit API lands, callers flip from -/// this helper to the real implementation. -public enum PendingStub { - private struct Body: Encodable { - let error: String - let endpoint: String - let tracking: String - - init(endpoint: String, tracking: String) { - self.error = "not_implemented" - self.endpoint = endpoint - self.tracking = tracking - } - } - - /// Print the standard "not yet implemented" banner for `endpoint`, citing - /// the GitHub issue tracking the underlying MistKit wrapper. - public static func printPending(endpoint: String, trackingIssue: Int) { - print( - "⚠️ \(endpoint) — not yet implemented " - + "(pending MistKit support, tracked in #\(trackingIssue))" - ) - } - - /// Encode the standard 501 response body — `{"error":"not_implemented", - /// "endpoint":"…","tracking":"#N"}` — for the web server stubs. - public static func responseJSON(endpoint: String, trackingIssue: Int) throws -> Data { - try JSONEncoder().encode( - Body(endpoint: endpoint, tracking: "#\(trackingIssue)") - ) - } -} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AcceptConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AcceptConfigTests.swift new file mode 100644 index 00000000..f2866d29 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AcceptConfigTests.swift @@ -0,0 +1,53 @@ +// +// AcceptConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistDemoKit + +@Suite("AcceptConfig Tests") +internal struct AcceptConfigTests { + @Test("AcceptConfig stores short GUIDs and defaults") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = AcceptConfig(base: baseConfig, shortGUIDs: ["abc123"]) + + #expect(config.shortGUIDs == ["abc123"]) + #expect(config.fetchRootRecord == nil) + #expect(config.fields == nil) + #expect(config.output == .json) + } + + @Test("AcceptCommand has correct static properties") + internal func commandMetadata() { + #expect(AcceptCommand.commandName == "accept") + #expect(AcceptCommand.abstract.contains("accept")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/ResolveConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ResolveConfigTests.swift new file mode 100644 index 00000000..3cd58d01 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/ResolveConfigTests.swift @@ -0,0 +1,61 @@ +// +// ResolveConfigTests.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistDemoKit + +@Suite("ResolveConfig Tests") +internal struct ResolveConfigTests { + @Test("ResolveConfig stores short GUIDs and defaults") + internal func defaults() async throws { + let baseConfig = try await MistDemoConfig() + let config = ResolveConfig(base: baseConfig, shortGUIDs: ["abc123"]) + + #expect(config.shortGUIDs == ["abc123"]) + #expect(config.fetchRootRecord == nil) + #expect(config.fields == nil) + #expect(config.output == .json) + } + + @Test("ResolveConfig parses short GUID from share URL path") + internal func parseShareURL() { + let guid = ResolveConfig.parseShortGUID( + fromShareURL: "https://www.icloud.com/share/abc123" + ) + #expect(guid == "abc123") + } + + @Test("ResolveCommand has correct static properties") + internal func commandMetadata() { + #expect(ResolveCommand.commandName == "resolve") + #expect(ResolveCommand.abstract.contains("resolve")) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index 299c1ad3..70f74571 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -154,5 +154,13 @@ internal let recordName: String? internal let database: MistKit.Database } + + /// Captured arguments from the most recent `webResolveShares` / + /// `webAcceptShares` call. + internal struct ResolveOrAcceptSharesCall: Sendable { + internal let shortGUIDs: [String] + internal let fetchRootRecord: Bool? + internal let fields: [String]? + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift new file mode 100644 index 00000000..19d2eecd --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift @@ -0,0 +1,78 @@ +// +// MockBackend+ShareOperations.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import MistKit + + @testable import MistDemoKit + + extension MockBackend { + internal func webResolveShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] { + lastResolveShares = ResolveOrAcceptSharesCall( + shortGUIDs: shortGUIDs, + fetchRootRecord: fetchRootRecord, + fields: fields + ) + try consumePendingError() + return shortGUIDs.map { guid in + ShareRecordInfo( + shortGUID: ShortGUID(value: guid), + rootRecordName: "stub-root-\(guid)", + participantPermission: .readWrite, + participantStatus: .accepted + ) + } + } + + internal func webAcceptShares( + shortGUIDs: [String], + fetchRootRecord: Bool?, + fields: [String]? + ) async throws -> [ShareRecordInfo] { + lastAcceptShares = ResolveOrAcceptSharesCall( + shortGUIDs: shortGUIDs, + fetchRootRecord: fetchRootRecord, + fields: fields + ) + try consumePendingError() + return shortGUIDs.map { guid in + ShareRecordInfo( + shortGUID: ShortGUID(value: guid), + rootRecordName: "stub-root-\(guid)", + participantPermission: .readWrite, + participantStatus: .accepted + ) + } + } + } +#endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift index ef7baeb6..8d0d5ab8 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend.swift @@ -61,6 +61,8 @@ internal var lastRegisterToken: RegisterTokenCall? internal var lastRereferenceAsset: RereferenceAssetCall? internal var lastUploadAsset: UploadAssetCall? + internal var lastResolveShares: ResolveOrAcceptSharesCall? + internal var lastAcceptShares: ResolveOrAcceptSharesCall? private var pendingError: String? /// Stub subscriptions (tests can seed); defaults to one query subscription. From 9bffc7b9a3755b53933457e564cedfbe5308b02a Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 15:54:10 -0400 Subject: [PATCH 3/5] Add createShare and tighten share domain models. Supports #437 with a curated createShare path (cloudkit.share wire type), MistDemo sharer/sharee integration, and required share fields that fail loudly when incomplete. Co-authored-by: Cursor --- .claude/memory/MEMORY.md | 1 + ...oject_cloudkit_share_record_type_casing.md | 7 + AGENTS.md | 8 +- .../MistDemoKit/Commands/AcceptCommand.swift | 2 +- .../Commands/AuthTokenCommand.swift | 8 +- .../MistDemoKit/Commands/ResolveCommand.swift | 2 +- .../Commands/TestPrivateCommand.swift | 26 +- .../Commands/TestPublicCommand.swift | 4 +- .../MistDemoKit/Commands/WebCommand.swift | 3 +- .../Configuration/AuthTokenConfig.swift | 13 +- .../Configuration/MistDemoConfig.swift | 16 ++ .../Configuration/TestPrivateConfig.swift | 20 +- .../Integration/IntegrationTestError.swift | 18 ++ .../Integration/IntegrationTestRunner.swift | 8 +- .../Integration/PhaseContext.swift | 13 +- .../Phases/ShareCreateAndAcceptPhase.swift | 216 +++++++++++++++ .../Tests/PrivateDatabaseTest.swift | 12 +- .../Sources/MistDemoKit/Resources/js/auth.js | 10 +- .../MistDemoKit/Server/WebServer.swift | 9 +- .../Configuration/AuthTokenConfigTests.swift | 8 +- .../TestPrivateConfigTests.swift | 59 +--- .../MistDemoTests/Server/WebServerTests.swift | 8 +- README.md | 1 + .../CloudKitService+CreateShare.swift | 252 ++++++++++++++++++ Sources/MistKit/Models/ConversionError.swift | 20 ++ Sources/MistKit/Models/RecordOperation.swift | 21 +- .../MistKit/Models/Sharing/CreatedShare.swift | 76 ++++++ .../Sharing/ShareAcceptanceStatus.swift | 11 + .../MistKit/Models/Sharing/ShareInfo.swift | 72 +++-- .../Models/Sharing/ShareParticipant.swift | 57 ++-- .../Models/Sharing/ShareParticipantType.swift | 10 + .../Models/Sharing/SharePermission.swift | 26 ++ .../Models/Sharing/SharePotentialMatch.swift | 14 +- .../Models/Sharing/ShareRecordInfo.swift | 84 ++++-- .../Models/Sharing/ShareTargetReference.swift | 62 +++++ .../MistKit/Models/Users/UserIdentity.swift | 19 ++ .../Models/Users/UserIdentityLookupInfo.swift | 10 + .../Components.Schemas.RecordOperation.swift | 12 +- .../CloudKitServiceTests.Sharing+Accept.swift | 4 +- .../CloudKitServiceTests.Sharing+Create.swift | 204 ++++++++++++++ ...CloudKitServiceTests.Sharing+Helpers.swift | 77 +++++- ...CloudKitServiceTests.Sharing+Resolve.swift | 8 +- ...oudKitServiceTests.Sharing+ShareInfo.swift | 23 +- .../Models/Sharing/ShareModelTests.swift | 59 ++-- 44 files changed, 1400 insertions(+), 193 deletions(-) create mode 100644 .claude/memory/project_cloudkit_share_record_type_casing.md create mode 100644 Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift create mode 100644 Sources/MistKit/CloudKitService/CloudKitService+CreateShare.swift create mode 100644 Sources/MistKit/Models/Sharing/CreatedShare.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareTargetReference.swift create mode 100644 Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 9ccba95a..2f79e7e1 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -36,3 +36,4 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [beta.4 worktree layout](project_beta4_worktree_layout.md) — Remaining v1.0.0-beta.4 issues are developed in parallel worktrees under MistKit.git/wt-, PR'd to the v1.0.0-beta.4 base - [#419 already fixed in beta.3](project_419_fixed_in_beta3.md) — MistDemoApp view inits shipped in 5a58120; verified building on macOS Swift 6.3.2, do not re-implement - [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead. +- [cloudkit.share wire casing](project_cloudkit_share_record_type_casing.md) — Live API wants `cloudkit.share` (lowercase k); archived docs' `cloudKit.share` yields "Cannot share - no such record exists to share" diff --git a/.claude/memory/project_cloudkit_share_record_type_casing.md b/.claude/memory/project_cloudkit_share_record_type_casing.md new file mode 100644 index 00000000..37c5d0bd --- /dev/null +++ b/.claude/memory/project_cloudkit_share_record_type_casing.md @@ -0,0 +1,7 @@ +# cloudkit.share wire casing + +Apple's archived CloudKit Web Services "Sharing Records" docs write the share record type as `cloudKit.share`. The live `records/modify` API only accepts / returns `cloudkit.share` (lowercase `k`). + +Creating with `cloudKit.share` fails with `Cannot share - no such record exists to share` even when the root exists with `shortGUID` + `stableUrl`. Creating with `cloudkit.share` succeeds (share-only atomic modify after root create with `createShortGUID: true` + `forRecord.recordChangeTag`). + +MistKit constant: `ShareInfo.recordType == "cloudkit.share"`. diff --git a/AGENTS.md b/AGENTS.md index 4d69e8ae..f73903b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,6 +197,7 @@ MistKit/ | `CloudKitService+UserIdentityChunking.swift` | `discoverAllUserIdentities(lookupInfos:batchSize:)` — auto-chunking convenience over `discoverUserIdentities` | | `CloudKitService+BatchChunking.swift` | internal `chunkedBatches` helper backing the auto-chunking conveniences | | `CloudKitService+ShareOperations.swift` | `resolveShares(_:)`, `acceptShares(_:)` *(public DB + web-auth, fixed — no `database:` parameter)* | +| `CloudKitService+CreateShare.swift` | `createShare(...)` *(private custom zone + web-auth; returns ``CreatedShare``)* | | `CloudKitService+AssetOperations.swift` | `uploadAssets`, `requestAssetUploadURL` | | `CloudKitService+AssetUpload.swift` | `uploadAssetData` | | `CloudKitService+RecordManaging.swift` | record-managing convenience surface | @@ -216,11 +217,12 @@ MistKit/ - `lookupUsersByEmail(_:)` → POST `/users/lookup/email` — returns `[UserIdentity]`. - `lookupUsersByRecordName(_:)` → POST `/users/lookup/id` — returns `[UserIdentity]`. -**Share Operations (issues #41 / #42 — public DB + web-auth required):** -- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUID]` into `[ShareRecordInfo]` (root record, `cloudKit.share` record, owner identity, the caller's participation). +**Share Operations (issues #41 / #42 / #437 — create needs private custom zone + web-auth; resolve/accept are public DB + web-auth):** +- `createShare(...)` → `records/modify` — creates a root (`createShortGUID`) plus `cloudkit.share`, returns ``CreatedShare`` (`shortGUID`, share URL, ``ShareInfo``, root ``RecordInfo``). +- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUID]` into `[ShareRecordInfo]` (root record, `cloudkit.share` record, owner identity, the caller's participation). - `acceptShares(_:)` → POST `/records/accept` — accepts `[ShortGUID]` on behalf of the current user; returns the same `[ShareRecordInfo]` shape reporting the caller's resulting participation. -Both endpoints are documented **only** in Apple's archived CloudKit Web Services Reference (`FetchingRecordInformation` / `AcceptingShareRecords`), which fixes the path's database scope to `public`; they act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose **no** `database:` parameter. Both validate the request as a whole: a bad short GUID fails the entire call rather than producing a per-item failure, so there is no `RecordResult`-style failure variant. +`createShare` writes against the caller's `database:` (typically `.private`) in a custom `zoneID`. Resolve/accept are documented **only** in Apple's archived CloudKit Web Services Reference (`FetchingRecordInformation` / `AcceptingShareRecords`), which fixes the path's database scope to `public`; they act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose **no** `database:` parameter. Both validate the request as a whole: a bad short GUID fails the entire call rather than producing a per-item failure, so there is no `RecordResult`-style failure variant. Set `ShortGUID.shouldFetchRootRecord` to have CloudKit include the shared root record, optionally narrowed by `rootRecordDesiredKeys`. When CloudKit cannot match the caller to exactly one invited participant, `ShareRecordInfo.potentialMatchList` is non-empty and the user must choose which invitation they are claiming. Domain models live in `Sources/MistKit/Models/Sharing/`. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift index 57790233..d61f9933 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift @@ -107,7 +107,7 @@ public struct AcceptCommand: MistDemoCommand, OutputFormatting { "✅ Accepted \(results.count) share\(results.count == 1 ? "" : "s")" ) for result in results { - print(" - shortGUID: \(result.shortGUID?.value ?? "-")") + print(" - shortGUID: \(result.shortGUID.value)") print(" rootRecordName: \(result.rootRecordName ?? "-")") print( " participantStatus: \(result.participantStatus?.rawValue ?? "-")" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift index 596e65fa..5d4ab2bb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AuthTokenCommand.swift @@ -56,6 +56,8 @@ --host Server host (default: 127.0.0.1) --browser Open browser on startup (default for auth-token) --no-browser Don't open browser on startup (overrides --browser) + --reset-auth Sign out any persisted CloudKit JS session before + capturing (force Apple ID picker) """ internal let config: AuthTokenConfig @@ -110,6 +112,9 @@ /// Executes the command. public func execute() async throws { print("📍 Server URL: http://\(config.host):\(config.port)") + if config.resetAuth { + print("🔄 Reset auth — browser will clear any persisted Apple ID session.") + } let tokenStore = WebAuthTokenStore() let server = WebServer( @@ -123,7 +128,8 @@ containerIdentifier: config.containerIdentifier, environment: config.environment ), - terminatesAfterAuth: true + terminatesAfterAuth: true, + resetAuth: config.resetAuth ) let app = Application( router: try server.makeRouter(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift index ec5f2df0..b1e49919 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift @@ -107,7 +107,7 @@ public struct ResolveCommand: MistDemoCommand, OutputFormatting { "✅ Resolved \(results.count) share\(results.count == 1 ? "" : "s")" ) for result in results { - print(" - shortGUID: \(result.shortGUID?.value ?? "-")") + print(" - shortGUID: \(result.shortGUID.value)") print(" rootRecordName: \(result.rootRecordName ?? "-")") print( " participantStatus: \(result.participantStatus?.rawValue ?? "-")" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift index f069a43c..919af931 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPrivateCommand.swift @@ -60,6 +60,15 @@ public struct TestPrivateCommand: MistDemoCommand { Email for users/lookup/email phase (CLOUDKIT_LOOKUP_EMAIL). Must belong to an iCloud account discoverable to the caller, otherwise the phase skips. + --sharee-web-auth-token + Web-auth token for the sharee account + (CLOUDKIT_SHAREE_WEB_AUTH_TOKEN). Obtain via `mistdemo auth-token` + while signed in as the sharee. Primary CLOUDKIT_WEB_AUTH_TOKEN + remains the sharer. + --sharee-email + iCloud email of the sharee (CLOUDKIT_SHAREE_EMAIL), used as the + invite lookup info when creating the share. Both sharee options + are required for the create→accept phase; otherwise it skips. EXAMPLES: mistdemo test-private --verbose @@ -68,7 +77,9 @@ public struct TestPrivateCommand: MistDemoCommand { NOTES: - Requires CLOUDKIT_API_TOKEN and - CLOUDKIT_WEB_AUTH_TOKEN + CLOUDKIT_WEB_AUTH_TOKEN (sharer) + - Optional sharee: CLOUDKIT_SHAREE_WEB_AUTH_TOKEN + + CLOUDKIT_SHAREE_EMAIL - Use 'test-public' for public-database tests """ @@ -90,6 +101,15 @@ public struct TestPrivateCommand: MistDemoCommand { // them. Per-call resolution picks the right token manager. let supportsUserContextPhases = config.base.hasUserContextCredentials + let shareeService: CloudKitService? + if let shareeToken = config.shareeWebAuthToken, !shareeToken.isEmpty { + shareeService = try MistKitClientFactory.create( + for: config.base.with(webAuthToken: shareeToken) + ) + } else { + shareeService = nil + } + let runner = IntegrationTestRunner( service: service, supportsUserContextPhases: supportsUserContextPhases, @@ -100,7 +120,9 @@ public struct TestPrivateCommand: MistDemoCommand { skipCleanup: config.skipCleanup, verbose: config.verbose, lookupEmail: config.lookupEmail, - shareShortGUID: config.shareShortGUID + shareShortGUID: config.shareShortGUID, + shareeService: shareeService, + shareeEmail: config.shareeEmail ) try await runner.runPrivateWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift index 8f8509e8..9abc7e1e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/TestPublicCommand.swift @@ -105,7 +105,9 @@ public struct TestPublicCommand: MistDemoCommand { skipCleanup: config.skipCleanup, verbose: config.verbose, lookupEmail: config.lookupEmail, - shareShortGUID: config.shareShortGUID + shareShortGUID: config.shareShortGUID, + shareeService: nil, + shareeEmail: nil ) try await runner.runBasicWorkflow() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift index abb63ace..87f47849 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/WebCommand.swift @@ -103,7 +103,8 @@ environment: config.environment, serverToServer: try makeServerToServerCredentials() ), - terminatesAfterAuth: false + terminatesAfterAuth: false, + resetAuth: false ) let router = try server.makeRouter() diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 632b20a9..4d89b79e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -53,6 +53,10 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { /// command's whole reason for existing, so a hands-off flow is the /// expected UX. public let openBrowser: Bool + /// When `true`, the browser flow signs out any persisted CloudKit JS + /// session before `setUpAuth`, so the user must pick an Apple ID again + /// (`--reset-auth` / `CLOUDKIT_RESET_AUTH`). + public let resetAuth: Bool /// Creates a new instance. public init( @@ -62,7 +66,8 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { environment: MistKit.Environment = .development, port: Int = 8_080, host: String = "127.0.0.1", - openBrowser: Bool = true + openBrowser: Bool = true, + resetAuth: Bool = false ) { self.apiToken = apiToken self.containerIdentifier = containerIdentifier @@ -70,6 +75,7 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { self.port = port self.host = host self.openBrowser = openBrowser + self.resetAuth = resetAuth } /// Parse configuration from command line arguments. @@ -114,6 +120,8 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { configReader: configReader, default: true ) + let resetAuth = + configReader.bool(forKey: "reset.auth", default: false) self.init( apiToken: apiToken, @@ -121,7 +129,8 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { environment: environment, port: port, host: host, - openBrowser: openBrowser + openBrowser: openBrowser, + resetAuth: resetAuth ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 9b307edb..858dd4eb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -194,6 +194,22 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { /// Returns a copy with the given database override. internal func with( database: MistKit.Database + ) -> MistDemoConfig { + with(database: database, webAuthToken: webAuthToken) + } + + /// Returns a copy with the given web-auth token override (same API token / + /// container / environment). Used to build a sharee `CloudKitService` while + /// the primary config remains the sharer. + internal func with( + webAuthToken: String? + ) -> MistDemoConfig { + with(database: database, webAuthToken: webAuthToken) + } + + private func with( + database: MistKit.Database, + webAuthToken: String? ) -> MistDemoConfig { MistDemoConfig( containerIdentifier: containerIdentifier, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index 381cb254..3fe07738 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -54,6 +54,14 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { /// Unused by `PrivateDatabaseTest` today (those phases are public-DB-only) /// but kept for symmetry with `TestPublicConfig`. public let shareShortGUID: String? + /// Optional web-auth token for the **sharee** account + /// (`CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`). Together with ``shareeEmail``, enables + /// the create→accept share roundtrip phase. The primary + /// `CLOUDKIT_WEB_AUTH_TOKEN` remains the **sharer**. + public let shareeWebAuthToken: String? + /// Optional iCloud email of the sharee (`CLOUDKIT_SHAREE_EMAIL`), used as + /// the invitee lookup info when creating the share. + public let shareeEmail: String? /// Creates a new instance. public init( @@ -63,7 +71,9 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { skipCleanup: Bool = false, verbose: Bool = false, lookupEmail: String? = nil, - shareShortGUID: String? = nil + shareShortGUID: String? = nil, + shareeWebAuthToken: String? = nil, + shareeEmail: String? = nil ) { self.base = base self.recordCount = recordCount @@ -72,6 +82,8 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { self.verbose = verbose self.lookupEmail = lookupEmail self.shareShortGUID = shareShortGUID + self.shareeWebAuthToken = shareeWebAuthToken + self.shareeEmail = shareeEmail } /// Parse configuration from command line arguments. @@ -113,6 +125,8 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { configuration.bool(forKey: "verbose", default: false) let lookupEmail = configuration.string(forKey: "lookup.email") let shareShortGUID = configuration.string(forKey: "share.short.guid") + let shareeWebAuthToken = configuration.string(forKey: "sharee.web.auth.token") + let shareeEmail = configuration.string(forKey: "sharee.email") self.init( base: baseConfig, @@ -121,7 +135,9 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { skipCleanup: skipCleanup, verbose: verbose, lookupEmail: lookupEmail, - shareShortGUID: shareShortGUID + shareShortGUID: shareShortGUID, + shareeWebAuthToken: shareeWebAuthToken, + shareeEmail: shareeEmail ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift index 527baeff..9b4e3e51 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestError.swift @@ -40,6 +40,12 @@ internal enum IntegrationTestError: LocalizedError, Sendable { case noRecordsCreated case missingWebAuthToken case missingPhaseState(String) + case shareResolveEmpty + case shareAcceptEmpty + case shareStillInvited + /// `CLOUDKIT_WEB_AUTH_TOKEN` and `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN` resolve + /// to the same CloudKit user (`users/caller` record name). + case shareeSameAsSharer(userRecordName: String) internal var errorDescription: String? { switch self { @@ -62,6 +68,18 @@ internal enum IntegrationTestError: LocalizedError, Sendable { "Web auth token is required for private database tests. Run 'mistdemo auth-token' first." case .missingPhaseState(let key): return "Required phase state '\(key)' is missing — preceding phase did not run" + case .shareResolveEmpty: + return "records/resolve returned no results for the created share" + case .shareAcceptEmpty: + return "records/accept returned no results for the created share" + case .shareStillInvited: + return "records/accept left the sharee in INVITED status" + case .shareeSameAsSharer(let userRecordName): + return """ + CLOUDKIT_WEB_AUTH_TOKEN and CLOUDKIT_SHAREE_WEB_AUTH_TOKEN point to \ + the same user (\(userRecordName)). Capture a second token with \ + `mistdemo auth-token` while signed into a different Apple ID. + """ } } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift index 3c3170d8..aaa16f5a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/IntegrationTestRunner.swift @@ -49,6 +49,10 @@ internal struct IntegrationTestRunner { internal let lookupEmail: String? /// Optional share short GUID forwarded to `PhaseContext.shareShortGUID`. internal let shareShortGUID: String? + /// Optional sharee service forwarded to `PhaseContext.shareeService`. + internal let shareeService: CloudKitService? + /// Optional sharee email forwarded to `PhaseContext.shareeEmail`. + internal let shareeEmail: String? /// Run the public-database workflow. internal func runBasicWorkflow() async throws { @@ -74,7 +78,9 @@ internal struct IntegrationTestRunner { skipCleanup: skipCleanup, verbose: verbose, lookupEmail: lookupEmail, - shareShortGUID: shareShortGUID + shareShortGUID: shareShortGUID, + shareeService: shareeService, + shareeEmail: shareeEmail ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift index 0b19a1ef..89e86fdb 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/PhaseContext.swift @@ -47,7 +47,16 @@ internal struct PhaseContext: Sendable { /// Optional share short GUID used by `ResolveRecordsPhase` and /// `AcceptSharesPhase` to exercise `records/resolve` / `records/accept` /// against a known share. There is no way to mint a short GUID from - /// within this pipeline — it must come from a share created out of band - /// — so both phases skip when this is `nil`. + /// within the public pipeline — it must come from a share created out of + /// band — so both phases skip when this is `nil`. The private pipeline + /// instead uses ``shareeService`` + ``shareeEmail`` to create and accept. internal let shareShortGUID: String? + /// Optional service authenticated as the **sharee** (same API token / + /// container, `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`). When set with + /// ``shareeEmail``, `ShareCreateAndAcceptPhase` creates a share as the + /// sharer (`service`) and accepts it as this sharee. + internal let shareeService: CloudKitService? + /// Optional iCloud email of the sharee (`CLOUDKIT_SHAREE_EMAIL`), used as + /// the invite lookup info when creating the share. + internal let shareeEmail: String? } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift new file mode 100644 index 00000000..3161aa8f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift @@ -0,0 +1,216 @@ +// +// ShareCreateAndAcceptPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Creates a share as the sharer (private DB), then resolves and accepts it +/// as the sharee (public + web-auth). +/// +/// Skips when `PhaseContext.shareeService` or `PhaseContext.shareeEmail` is +/// missing (`CLOUDKIT_SHAREE_WEB_AUTH_TOKEN` / `CLOUDKIT_SHAREE_EMAIL`). Fails +/// early when both web-auth tokens resolve to the same `users/caller` record +/// name. Self-cleaning: deletes the share root and zone afterward. +internal struct ShareCreateAndAcceptPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Create share and accept as sharee" + internal static let emoji = "🤝" + internal static let apiName = "createShare+acceptShares" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let shareeService = context.shareeService, + let shareeEmail = context.shareeEmail, + !shareeEmail.isEmpty + else { + print( + """ + ⏭️ Skipping — set CLOUDKIT_SHAREE_WEB_AUTH_TOKEN and \ + CLOUDKIT_SHAREE_EMAIL to exercise create→accept. \ + CLOUDKIT_WEB_AUTH_TOKEN remains the sharer. + """ + ) + return NoState() + } + + // Distinct Apple IDs are required: inviting yourself is not a useful + // create→accept roundtrip. Compare users/caller record names up front. + let sharerIdentity = try await context.service.fetchCaller() + let shareeIdentity = try await shareeService.fetchCaller() + if sharerIdentity.userRecordName == shareeIdentity.userRecordName { + throw IntegrationTestError.shareeSameAsSharer( + userRecordName: sharerIdentity.userRecordName + ) + } + if context.verbose { + print(" Sharer userRecordName: \(sharerIdentity.userRecordName)") + print(" Sharee userRecordName: \(shareeIdentity.userRecordName)") + } + + let zoneName = "mistkit-share-\(UUID().uuidString.lowercased())" + let zoneID = ZoneID(zoneName: zoneName) + let rootRecordName = "mistkit-share-root-\(UUID().uuidString.lowercased())" + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + } + + do { + let created = try await context.service.createShare( + rootRecordType: MistDemoConfig.recordType, + rootRecordName: rootRecordName, + rootFields: [ + "title": .string("Share roundtrip"), + "index": .int64(0), + ], + zoneID: zoneID, + publicPermission: .none, + participants: [ + ShareParticipant( + userIdentity: UserIdentity( + lookupInfo: UserIdentityLookupInfo(emailAddress: shareeEmail) + ), + permission: .readWrite, + type: .user, + acceptanceStatus: .invited + ) + ], + database: context.database + ) + + print("✅ Created share \(created.shortGUID)") + if context.verbose { + print(" Share URL: \(created.shareURL.absoluteString)") + } + + let shortGUID = ShortGUID( + value: created.shortGUID, + shouldFetchRootRecord: true + ) + + let resolved = try await shareeService.resolveShares([shortGUID]) + guard let resolveInfo = resolved.first else { + throw IntegrationTestError.shareResolveEmpty + } + if context.verbose { + print(" Resolved root: \(resolveInfo.rootRecordName ?? "-")") + print( + " Resolve status: \(resolveInfo.participantStatus?.rawValue ?? "-")" + ) + } + + let accepted = try await shareeService.acceptShares([shortGUID]) + guard let acceptInfo = accepted.first else { + throw IntegrationTestError.shareAcceptEmpty + } + if let status = acceptInfo.participantStatus, status == .invited { + throw IntegrationTestError.shareStillInvited + } + print( + "✅ Sharee accepted — status: " + + "\(acceptInfo.participantStatus?.rawValue ?? "-")" + ) + + try await cleanup( + sharer: context.service, + database: context.database, + zoneID: zoneID, + rootRecordName: created.rootRecord.recordName, + shareRecordName: created.shareRecordName, + verbose: context.verbose + ) + } catch { + try? await cleanup( + sharer: context.service, + database: context.database, + zoneID: zoneID, + rootRecordName: rootRecordName, + shareRecordName: nil, + verbose: context.verbose + ) + throw error + } + + return NoState() + } + + private func cleanup( + sharer: CloudKitService, + database: MistKit.Database, + zoneID: ZoneID, + rootRecordName: String, + shareRecordName: String?, + verbose: Bool + ) async throws { + var ops = [ + RecordOperation( + operationType: .forceDelete, + recordType: MistDemoConfig.recordType, + recordName: rootRecordName + ) + ] + if let shareRecordName { + ops.append( + RecordOperation( + operationType: .forceDelete, + recordType: ShareInfo.recordType, + recordName: shareRecordName + ) + ) + } + do { + _ = try await sharer.modifyRecords(ops, zoneID: zoneID, database: database) + if verbose { + print(" ✅ Deleted share root in zone \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Share record cleanup failed: \(error)") + } + } + + do { + try await sharer.deleteZone(zoneName: zoneID.zoneName, database: database) + if verbose { + print(" ✅ Deleted zone: \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Zone cleanup failed: \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 55468c82..abac15e6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -35,12 +35,11 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { internal let database: MistKit.Database = .private // User-identity phases (`FetchCallerPhase`, `DiscoverUserIdentitiesPhase`, - // `users/lookup/*`) and the sharing phases (`ResolveRecordsPhase`, - // `AcceptSharesPhase`) are intentionally absent: CloudKit Web Services - // rejects these endpoints on the private database with "endpoint not - // applicable in the database type 'privatedb'". They only belong in the - // public-database pipeline; the service resolves web-auth credentials per - // call when needed. + // `users/lookup/*`) stay on the public pipeline: CloudKit rejects those + // endpoints on private with "endpoint not applicable". Share create uses the + // private sharer service; resolve/accept are public-scoped calls run from + // the sharee service (`ShareCreateAndAcceptPhase`) when + // CLOUDKIT_SHAREE_WEB_AUTH_TOKEN + CLOUDKIT_SHAREE_EMAIL are set. internal let phases: [any IntegrationPhase] = [ ListZonesPhase(), ModifyZonesPhase(), @@ -63,6 +62,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { SubscriptionRoundtripPhase(), TokenRoundtripPhase(), NotificationRoundtripPhase(), + ShareCreateAndAcceptPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js index ef8ab098..81975a6c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/auth.js @@ -117,13 +117,21 @@ async function initializeCloudKit() { containerIdentifier: serverConfig.containerIdentifier, apiTokenAuth: { apiToken: serverConfig.apiToken, - persist: true, + persist: !serverConfig.resetAuth, signInButton: { id: 'signin-button', theme: 'black' }, }, environment: serverConfig.environment || 'development', }], }); container = CloudKit.getDefaultContainer(); + if (serverConfig.resetAuth) { + setStatus(authStatusDiv, 'Resetting persisted Apple ID session...', 'success'); + try { + await container.signOut(); + } catch (_) { + // No active session — proceed to sign-in. + } + } const userIdentity = await container.setUpAuth(); if (userIdentity) { setStatus(authStatusDiv, 'Already signed in. Capturing token...', 'success'); diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift index d09f721e..f96deba9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer.swift @@ -56,6 +56,9 @@ internal let containerIdentifier: String internal let environment: String internal let publicDatabaseAvailable: Bool + /// When `true`, the browser signs out any persisted CloudKit JS session + /// before `setUpAuth` (auth-token `--reset-auth`). + internal let resetAuth: Bool } internal let apiToken: String @@ -68,6 +71,9 @@ /// signal the browser that the server is about to shut down (auth-token /// flow). When `false`, returns `204 No Content` (web flow stays up). internal let terminatesAfterAuth: Bool + /// Forwarded to `GET /api/config` so the browser can clear a persisted + /// Apple ID session before capturing a new token. + internal let resetAuth: Bool internal static func jsonResponse( status: HTTPResponse.Status, bytes: Data @@ -118,7 +124,8 @@ apiToken: apiToken, containerIdentifier: containerIdentifier, environment: environment.rawValue, - publicDatabaseAvailable: publicDatabaseAvailable + publicDatabaseAvailable: publicDatabaseAvailable, + resetAuth: resetAuth ) ) addConfigEndpoint(api: api, configData: configData) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift index 6632ce32..e2d93944 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -61,6 +61,7 @@ internal struct AuthTokenConfigTests { #expect(config.host == "127.0.0.1") // auth-token defaults to opening the browser. #expect(config.openBrowser == true) + #expect(config.resetAuth == false) } @Test("Memberwise init accepts custom values for every field") @@ -71,7 +72,8 @@ internal struct AuthTokenConfigTests { environment: .production, port: 9_000, host: "0.0.0.0", - openBrowser: false + openBrowser: false, + resetAuth: true ) #expect(config.apiToken == "tok") @@ -80,6 +82,7 @@ internal struct AuthTokenConfigTests { #expect(config.port == 9_000) #expect(config.host == "0.0.0.0") #expect(config.openBrowser == false) + #expect(config.resetAuth == true) } @Test("Configuration init throws missingRequired when api.token is absent") @@ -116,6 +119,7 @@ internal struct AuthTokenConfigTests { #expect(config.port == 8_080) #expect(config.host == "127.0.0.1") #expect(config.openBrowser == true) + #expect(config.resetAuth == false) } @Test("Configuration init honors every override key") @@ -127,6 +131,7 @@ internal struct AuthTokenConfigTests { "port": .init(integerLiteral: 9_090), "host": .init(stringLiteral: "192.168.1.10"), "no.browser": .init(booleanLiteral: true), + "reset.auth": .init(booleanLiteral: true), ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -137,6 +142,7 @@ internal struct AuthTokenConfigTests { #expect(config.port == 9_090) #expect(config.host == "192.168.1.10") #expect(config.openBrowser == false) + #expect(config.resetAuth == true) } @Test("--no-browser wins when both browser flags are set") diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index 44e1f26d..a02e30ec 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -1,6 +1,6 @@ // // TestPrivateConfigTests.swift -// MistDemoTests +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -28,62 +28,21 @@ // internal import Foundation -internal import MistKit internal import Testing @testable import MistDemoKit @Suite("TestPrivateConfig Tests") internal struct TestPrivateConfigTests { - @Test("Memberwise defaults: recordCount=10, assetSizeKB=100, flags false, lookupEmail nil") - internal func defaults() async throws { - let baseConfig = try await MistDemoConfig() - let config = TestPrivateConfig(base: baseConfig) - - #expect(config.recordCount == 10) - #expect(config.assetSizeKB == 100) - #expect(config.skipCleanup == false) - #expect(config.verbose == false) - #expect(config.lookupEmail == nil) - } - - @Test("Memberwise init accepts every custom value") - internal func customValues() async throws { - let baseConfig = try await MistDemoConfig() + @Test("TestPrivateConfig retains sharee credentials") + internal func retainsShareeCredentials() async throws { + let base = try await MistDemoConfig() let config = TestPrivateConfig( - base: baseConfig, - recordCount: 42, - assetSizeKB: 2_048, - skipCleanup: true, - verbose: true, - lookupEmail: "user@example.com" + base: base, + shareeWebAuthToken: "sharee-token-value", + shareeEmail: "sharee@example.com" ) - - #expect(config.recordCount == 42) - #expect(config.assetSizeKB == 2_048) - #expect(config.skipCleanup == true) - #expect(config.verbose == true) - #expect(config.lookupEmail == "user@example.com") - } - - @Test("Configuration init pins database to private regardless of input") - internal func pinsDatabaseToPrivate() async throws { - // Even though we configure the base for the public DB, TestPrivateConfig - // must override to `.private`. The init also requires web-auth credentials. - let baseConfig = try await MistDemoConfig( - database: .public(.prefers(.serverToServer)), - webAuthToken: "wat-xyz" - ) - let config = TestPrivateConfig(base: baseConfig.with(database: .private)) - - #expect(config.base.database == MistKit.Database.private) - } - - @Test("Memberwise init preserves base configuration values") - internal func preservesBase() async throws { - let baseConfig = try await MistDemoConfig(containerIdentifier: "iCloud.private.test") - let config = TestPrivateConfig(base: baseConfig) - - #expect(config.base.containerIdentifier == "iCloud.private.test") + #expect(config.shareeWebAuthToken == "sharee-token-value") + #expect(config.shareeEmail == "sharee@example.com") } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift index cf8cc456..29073997 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests.swift @@ -50,12 +50,14 @@ let containerIdentifier: String let environment: String let publicDatabaseAvailable: Bool + let resetAuth: Bool } internal static func makeFixture( authenticated: Bool = false, terminatesAfterAuth: Bool = false, - publicDatabaseAvailable: Bool = false + publicDatabaseAvailable: Bool = false, + resetAuth: Bool = false ) -> Fixture { let backend = MockBackend() let store = WebAuthTokenStore( @@ -69,7 +71,8 @@ publicDatabaseAvailable: publicDatabaseAvailable, tokenStore: store, backendFactory: factory, - terminatesAfterAuth: terminatesAfterAuth + terminatesAfterAuth: terminatesAfterAuth, + resetAuth: resetAuth ) return Fixture(server: server, tokenStore: store, backend: backend) } @@ -91,6 +94,7 @@ #expect(payload.containerIdentifier == "iCloud.test.container") #expect(payload.environment == "development") #expect(payload.publicDatabaseAvailable == false) + #expect(payload.resetAuth == false) } } } diff --git a/README.md b/README.md index 078f4f8f..358d6f03 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,7 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Record Information (records/resolve)](https://github.com/brightdigit/MistKit/issues/41) ✅ - [x] [Accepting Share Records (records/accept)](https://github.com/brightdigit/MistKit/issues/42) ✅ +- [x] [Curated createShare for share URL creation](https://github.com/brightdigit/MistKit/issues/437) ✅ ### Backlog / Post-beta diff --git a/Sources/MistKit/CloudKitService/CloudKitService+CreateShare.swift b/Sources/MistKit/CloudKitService/CloudKitService+CreateShare.swift new file mode 100644 index 00000000..1d480676 --- /dev/null +++ b/Sources/MistKit/CloudKitService/CloudKitService+CreateShare.swift @@ -0,0 +1,252 @@ +// +// CloudKitService+CreateShare.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI + +extension CloudKitService { + /// Create a CloudKit share for a new root record (`records/modify`). + /// + /// Performs two modify requests against `database` in `zoneID`, matching + /// Apple's Sharing Records sequence: + /// 1. Creates the root record with `createShortGUID: true` (stable URL). + /// 2. Creates a ``ShareInfo/recordType`` (`cloudkit.share`) with + /// `forRecord` (name + change tag), `atomic: true`, public permission, + /// and participants. + /// + /// Returns the generated short GUID and the standard iCloud share URL + /// (`https://www.icloud.com/share/{shortGUID}`). Share metadata is carried + /// on ``CreatedShare`` / ``ShareInfo`` — not on ``RecordInfo``. + /// + /// Custom zones are required for sharing; Apple's reference uses a private + /// custom zone. The service credentials must be able to write that database + /// (typically private + web-auth). + /// + /// - Parameters: + /// - rootRecordType: The record type for the shared root record. + /// - rootRecordName: Optional root record name; CloudKit generates one when + /// omitted. + /// - rootFields: Fields for the root record. + /// - zoneID: The custom zone that will hold the root and share records. + /// - publicPermission: Public access on the share (default `.none`). + /// - participants: Participants to invite (typically a sharee identified by + /// email lookup info, with `.invited` acceptance status). + /// - database: Database scope for the create (usually `.private`). + /// - Returns: The created share, including short GUID and invite URL. + /// - Throws: ``CloudKitError``. + public func createShare( + rootRecordType: String, + rootRecordName: String? = nil, + rootFields: [String: FieldValue] = [:], + zoneID: ZoneID, + publicPermission: SharePermission = .none, + participants: [ShareParticipant], + database: Database + ) async throws(CloudKitError) -> CreatedShare { + do { + // Pre-assign the root name so the share step can target it even if the + // caller omitted `rootRecordName`. + let resolvedRootName = + rootRecordName ?? "mistkit-share-root-\(UUID().uuidString.lowercased())" + let (rootRecord, rootChangeTag) = try await createShareRoot( + recordType: rootRecordType, + recordName: resolvedRootName, + fields: rootFields, + zoneID: zoneID, + database: database + ) + // Share creates must be atomic; forRecord must include the root's + // change tag. Wire type is `cloudkit.share` (lowercase k) — Apple's + // archived docs say `cloudKit.share`, which the server rejects with + // "Cannot share - no such record exists to share". + return try await createShareRecord( + rootRecord: rootRecord, + rootChangeTag: rootChangeTag, + zoneID: zoneID, + publicPermission: publicPermission, + participants: participants, + database: database + ) + } catch { + throw mapToCloudKitError(error, context: "createShare") + } + } + + private func createShareRoot( + recordType: String, + recordName: String, + fields: [String: FieldValue], + zoneID: ZoneID, + database: Database + ) async throws -> (RecordInfo, String) { + let rootSchemas = try await modifyRecordResponses( + [ + RecordOperation( + operationType: .create, + recordType: recordType, + recordName: recordName, + fields: fields, + createShortGUID: true + ) + ], + zoneID: zoneID, + database: database, + atomic: false + ) + guard let rootSchema = rootSchemas.first else { + throw CloudKitError.incompleteResponse( + reason: "createShare root create returned no records" + ) + } + let rootRecord = try RecordInfo(from: rootSchema) + guard let rootChangeTag = rootSchema.recordChangeTag else { + throw CloudKitError.incompleteResponse( + reason: + "createShare root create omitted recordChangeTag " + + "(required by forRecord when creating \(ShareInfo.recordType))" + ) + } + return (rootRecord, rootChangeTag) + } + + private func createShareRecord( + rootRecord: RecordInfo, + rootChangeTag: String, + zoneID: ZoneID, + publicPermission: SharePermission, + participants: [ShareParticipant], + database: Database + ) async throws -> CreatedShare { + let shareSchemas = try await modifyRecordResponses( + [ + RecordOperation( + operationType: .create, + recordType: ShareInfo.recordType, + recordName: nil, + fields: [:], + forRecord: ShareTargetReference( + recordName: rootRecord.recordName, + recordChangeTag: rootChangeTag + ), + publicPermission: publicPermission, + participants: participants + ) + ], + zoneID: zoneID, + database: database, + atomic: true + ) + guard let shareSchema = shareSchemas.first else { + throw CloudKitError.incompleteResponse( + reason: "createShare share create returned no records" + ) + } + guard let share = ShareInfo(from: shareSchema) else { + try ConversionError.shareIncomplete.reportAndThrow() + } + guard let shareRecordName = shareSchema.recordName else { + try ConversionError.recordMissingRecordName.reportAndThrow() + } + return CreatedShare( + shortGUID: share.shortGUID, + shareURL: CreatedShare.shareURL(forShortGUID: share.shortGUID), + share: share, + rootRecord: rootRecord, + shareRecordName: shareRecordName + ) + } + + /// Run `records/modify` and return raw ``Components.Schemas.RecordResponse`` + /// values in request order, preserving share keys that ``RecordInfo`` drops. + /// + /// - Parameters: + /// - operations: Record operations to send. + /// - zoneID: Zone that holds the records. + /// - database: Database scope for the modify. + /// - atomic: Required `true` when any operation creates a + /// `cloudkit.share` ("You can only create a share with atomic=true"). + /// - Returns: Record responses in request order. + /// - Throws: ``CloudKitError`` when the modify fails or a per-item + /// failure is returned. + private func modifyRecordResponses( + _ operations: [RecordOperation], + zoneID: ZoneID, + database: Database, + atomic: Bool + ) async throws -> [Components.Schemas.RecordResponse] { + let apiOperations = try operations.map { + try Components.Schemas.RecordOperation(from: $0) + } + let client = try self.client(for: database) + let response = try await client.modifyRecords( + .init( + path: .init( + version: "1", + container: containerIdentifier, + environment: .init(from: environment), + database: .init(from: database) + ), + body: .json( + .init( + operations: apiOperations, + atomic: atomic, + zoneID: Components.Schemas.ZoneID(from: zoneID), + desiredKeys: nil, + numbersAsStrings: nil + ) + ) + ) + ) + + let modifyResponse: Components.Schemas.ModifyResponse = + try await responseProcessor.processModifyRecordsResponse(response) + let items = modifyResponse.records ?? [] + var results: [Components.Schemas.RecordResponse] = [] + results.reserveCapacity(items.count) + for item in items { + switch item { + case .RecordOperationFailure(let failure): + throw CloudKitError.recordOperationFailed(OperationFailure(from: failure)) + case .RecordResponse(let record): + if record.recordName == nil, record.recordType == nil { + // CloudKit sometimes returns a per-item failure without `recordName` + // (OpenAPI requires it on RecordOperationFailure), which then + // decodes as an empty RecordResponse. Surface it as incomplete. + throw CloudKitError.incompleteResponse( + reason: + "createShare modify returned an empty record entry " + + "(likely a per-item failure without recordName)" + ) + } + results.append(record) + } + } + return results + } +} diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 05386546..5ff567ce 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -75,6 +75,16 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { /// A token response was missing or malformed a required field /// (`apnsEnvironment`/`apnsToken`/`webcourierURL`). case tokenMissingField(fieldName: String) + /// A `cloudkit.share` create response was missing its `shortGUID`. + case shareMissingShortGUID + /// A resolve/accept result was missing its `shortGUID`. + case shareResultMissingShortGUID + /// A `cloudkit.share` record was present but missing required share keys + /// (`shortGUID`, `publicPermission`, `owner`, `currentUserParticipant`, or + /// a convertible `participants` entry). + case shareIncomplete + /// A `potentialMatchList` entry was missing its `participantId`. + case sharePotentialMatchMissingParticipantId /// A human-readable description of what failed during conversion. public var errorDescription: String? { @@ -114,6 +124,16 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { + "subscription must declare at least one of [create, update, delete]" case .tokenMissingField(let fieldName): return "TokenResponse missing required field '\(fieldName)'" + case .shareMissingShortGUID: + return "cloudkit.share create response missing required shortGUID" + case .shareResultMissingShortGUID: + return "ShortGUIDResult missing required shortGUID" + case .shareIncomplete: + return "cloudkit.share record missing required share keys " + + "(shortGUID, publicPermission, owner, currentUserParticipant, " + + "or convertible participants)" + case .sharePotentialMatchMissingParticipantId: + return "potentialMatchList entry missing required participantId" } } } diff --git a/Sources/MistKit/Models/RecordOperation.swift b/Sources/MistKit/Models/RecordOperation.swift index a1b4b577..61c3528a 100644 --- a/Sources/MistKit/Models/RecordOperation.swift +++ b/Sources/MistKit/Models/RecordOperation.swift @@ -59,6 +59,17 @@ public struct RecordOperation: Sendable { public let fields: [String: FieldValue] /// Optional record change tag for optimistic locking public let recordChangeTag: String? + /// When `true`, ask CloudKit to mint a short GUID so this record can be + /// shared. Echoed back on the response as `shortGUID`. + public let createShortGUID: Bool? + /// When creating a `cloudKit.share` record, the root record being shared + /// (`forRecord`). + public let forRecord: ShareTargetReference? + /// When creating a `cloudKit.share` record, the public's read/write + /// permissions on the shared record. + public let publicPermission: SharePermission? + /// When creating a `cloudKit.share` record, the participants to invite. + public let participants: [ShareParticipant]? /// Initialize a record operation public init( @@ -66,13 +77,21 @@ public struct RecordOperation: Sendable { recordType: String, recordName: String?, fields: [String: FieldValue] = [:], - recordChangeTag: String? = nil + recordChangeTag: String? = nil, + createShortGUID: Bool? = nil, + forRecord: ShareTargetReference? = nil, + publicPermission: SharePermission? = nil, + participants: [ShareParticipant]? = nil ) { self.operationType = operationType self.recordType = recordType self.recordName = recordName self.fields = fields self.recordChangeTag = recordChangeTag + self.createShortGUID = createShortGUID + self.forRecord = forRecord + self.publicPermission = publicPermission + self.participants = participants } /// Convenience initializer for creating a new record diff --git a/Sources/MistKit/Models/Sharing/CreatedShare.swift b/Sources/MistKit/Models/Sharing/CreatedShare.swift new file mode 100644 index 00000000..9cb55ec4 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/CreatedShare.swift @@ -0,0 +1,76 @@ +// +// CreatedShare.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +/// The result of creating a CloudKit share via ``CloudKitService/createShare``. +/// +/// Carries the generated short GUID and invite URL alongside the root record +/// and share-specific metadata. Share keys stay here (and on ``ShareInfo``) +/// rather than on ``RecordInfo``, which models a plain record. +public struct CreatedShare: Sendable { + /// The short GUID CloudKit assigned to the share (and shared root). + public let shortGUID: String + /// The iCloud share invite URL (`https://www.icloud.com/share/{shortGUID}`). + public let shareURL: URL + /// Share-specific keys lifted from the `cloudkit.share` record. + public let share: ShareInfo + /// The root record that was shared. + public let rootRecord: RecordInfo + /// The record name of the created `cloudkit.share` record. + public let shareRecordName: String + + /// Initialize a created-share result. + /// - Parameters: + /// - shortGUID: The short GUID CloudKit assigned. + /// - shareURL: The iCloud share invite URL. + /// - share: Share-specific keys from the `cloudkit.share` record. + /// - rootRecord: The root record that was shared. + /// - shareRecordName: The `cloudkit.share` record name. + public init( + shortGUID: String, + shareURL: URL, + share: ShareInfo, + rootRecord: RecordInfo, + shareRecordName: String + ) { + self.shortGUID = shortGUID + self.shareURL = shareURL + self.share = share + self.rootRecord = rootRecord + self.shareRecordName = shareRecordName + } + + /// Build the standard iCloud share invite URL for a short GUID value. + public static func shareURL(forShortGUID shortGUID: String) -> URL { + // swiftlint:disable:next force_unwrapping + // swift-format-ignore: NeverForceUnwrap + URL(string: "https://www.icloud.com/share/\(shortGUID)")! + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift b/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift index 3125a66a..57dcbf3e 100644 --- a/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift +++ b/Sources/MistKit/Models/Sharing/ShareAcceptanceStatus.swift @@ -60,4 +60,15 @@ extension ShareAcceptanceStatus { case .UNKNOWN: self = .unknown } } + + internal var asShareParticipantPayload: + Components.Schemas.ShareParticipant.acceptanceStatusPayload + { + switch self { + case .invited: .INVITED + case .accepted: .ACCEPTED + case .removed: .REMOVED + case .unknown: .UNKNOWN + } + } } diff --git a/Sources/MistKit/Models/Sharing/ShareInfo.swift b/Sources/MistKit/Models/Sharing/ShareInfo.swift index 06d74ffb..719bb220 100644 --- a/Sources/MistKit/Models/Sharing/ShareInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareInfo.swift @@ -29,43 +29,53 @@ internal import MistKitOpenAPI -/// The share-specific keys carried by a `cloudKit.share` record. +/// The share-specific keys carried by a `cloudkit.share` record. /// /// CloudKit returns these alongside the ordinary Record Dictionary keys on /// share records. They are lifted out here because ``RecordInfo`` models a /// plain record and intentionally carries no sharing metadata. +/// +/// A successful lift requires `shortGUID`, `publicPermission`, `owner`, +/// `currentUserParticipant`, and a fully convertible `participants` list. +/// Records with none of the share keys return `nil` from `init?(from:)` +/// (plain records). Records that look like shares but omit a required key +/// also return `nil` — callers that already have a share record should treat +/// that as ``ConversionError/shareIncomplete``. public struct ShareInfo: Codable, Sendable { + /// Wire `recordType` for share records (`cloudkit.share`). + /// + /// Apple's archived CloudKit Web Services docs write `cloudKit.share`, but + /// the live API only accepts / returns the lowercase-`k` form. Using the + /// camelCase spelling yields "Cannot share - no such record exists to + /// share". + public static let recordType = "cloudkit.share" + /// The short GUID identifying this share. - public let shortGUID: String? - /// The record name of the shared record this share governs. - public let sharedRecordName: String? + public let shortGUID: String /// The public's read and write permissions on the shared record. - public let publicPermission: SharePermission? + public let publicPermission: SharePermission /// The participants in the share. public let participants: [ShareParticipant] /// The owner of the shared record. - public let owner: ShareParticipant? + public let owner: ShareParticipant /// The current user's participation in the share. - public let currentUserParticipant: ShareParticipant? + public let currentUserParticipant: ShareParticipant /// Initialize share information. /// - Parameters: /// - shortGUID: The short GUID identifying this share. - /// - sharedRecordName: The record name of the shared record. /// - publicPermission: The public's permissions on the shared record. /// - participants: The participants in the share. /// - owner: The owner of the shared record. /// - currentUserParticipant: The current user's participation. public init( - shortGUID: String? = nil, - sharedRecordName: String? = nil, - publicPermission: SharePermission? = nil, + shortGUID: String, + publicPermission: SharePermission, participants: [ShareParticipant] = [], - owner: ShareParticipant? = nil, - currentUserParticipant: ShareParticipant? = nil + owner: ShareParticipant, + currentUserParticipant: ShareParticipant ) { self.shortGUID = shortGUID - self.sharedRecordName = sharedRecordName self.publicPermission = publicPermission self.participants = participants self.owner = owner @@ -73,7 +83,7 @@ public struct ShareInfo: Codable, Sendable { } /// Lift the share-specific keys out of a record response, or return `nil` - /// when the record carries none of them (i.e. it is not a share record). + /// when the record is not a share / is an incomplete share. internal init?(from record: Components.Schemas.RecordResponse) { let hasShareKeys = record.shortGUID != nil || record.share != nil || record.publicPermission != nil @@ -82,11 +92,31 @@ public struct ShareInfo: Codable, Sendable { guard hasShareKeys else { return nil } - self.shortGUID = record.shortGUID - self.sharedRecordName = record.share?.recordName - self.publicPermission = record.publicPermission.map(SharePermission.init(from:)) - self.participants = record.participants?.map(ShareParticipant.init(from:)) ?? [] - self.owner = record.owner.map(ShareParticipant.init(from:)) - self.currentUserParticipant = record.currentUserParticipant.map(ShareParticipant.init(from:)) + + guard let shortGUID = record.shortGUID, + let publicPermission = record.publicPermission.map(SharePermission.init(from:)), + let ownerSchema = record.owner, + let owner = ShareParticipant(from: ownerSchema), + let currentSchema = record.currentUserParticipant, + let currentUserParticipant = ShareParticipant(from: currentSchema) + else { + return nil + } + + let wireParticipants = record.participants ?? [] + var participants: [ShareParticipant] = [] + participants.reserveCapacity(wireParticipants.count) + for schema in wireParticipants { + guard let participant = ShareParticipant(from: schema) else { + return nil + } + participants.append(participant) + } + + self.shortGUID = shortGUID + self.publicPermission = publicPermission + self.participants = participants + self.owner = owner + self.currentUserParticipant = currentUserParticipant } } diff --git a/Sources/MistKit/Models/Sharing/ShareParticipant.swift b/Sources/MistKit/Models/Sharing/ShareParticipant.swift index a28c5063..1f5221dd 100644 --- a/Sources/MistKit/Models/Sharing/ShareParticipant.swift +++ b/Sources/MistKit/Models/Sharing/ShareParticipant.swift @@ -31,18 +31,23 @@ internal import MistKitOpenAPI /// A participant in a shared record. /// -/// Participants appear on `cloudKit.share` records — as the `participants` +/// Participants appear on `cloudkit.share` records — as the `participants` /// list, the share's `owner`, and the caller's own `currentUserParticipant` -/// entry. +/// entry. Every field is required: a participant without identity, permission, +/// type, or acceptance status is not useful to callers. Wire responses that +/// omit any of those keys fail conversion (`init?(from:)` returns `nil`). +/// +/// Invitees may still carry a sparse ``UserIdentity`` (lookup email only, +/// ``UserRecordName/nonDiscoverable``) — that is identity present, not absent. public struct ShareParticipant: Codable, Sendable { - /// The identity of the participant, when CloudKit could resolve one. - public let userIdentity: UserIdentity? + /// The identity of the participant. + public let userIdentity: UserIdentity /// The participant's read and write permissions. - public let permission: SharePermission? + public let permission: SharePermission /// The kind of participant. - public let type: ShareParticipantType? + public let type: ShareParticipantType /// Whether the participant has accepted the share. - public let acceptanceStatus: ShareAcceptanceStatus? + public let acceptanceStatus: ShareAcceptanceStatus /// Initialize a share participant. /// - Parameters: @@ -51,10 +56,10 @@ public struct ShareParticipant: Codable, Sendable { /// - type: The kind of participant. /// - acceptanceStatus: Whether the participant accepted the share. public init( - userIdentity: UserIdentity? = nil, - permission: SharePermission? = nil, - type: ShareParticipantType? = nil, - acceptanceStatus: ShareAcceptanceStatus? = nil + userIdentity: UserIdentity, + permission: SharePermission, + type: ShareParticipantType, + acceptanceStatus: ShareAcceptanceStatus ) { self.userIdentity = userIdentity self.permission = permission @@ -62,10 +67,30 @@ public struct ShareParticipant: Codable, Sendable { self.acceptanceStatus = acceptanceStatus } - internal init(from schema: Components.Schemas.ShareParticipant) { - self.userIdentity = schema.userIdentity.map(UserIdentity.init(from:)) - self.permission = schema.permission.map(SharePermission.init(from:)) - self.type = schema._type.map(ShareParticipantType.init(from:)) - self.acceptanceStatus = schema.acceptanceStatus.map(ShareAcceptanceStatus.init(from:)) + /// Lift a participant from the wire schema, or return `nil` when any + /// required field is missing. + internal init?(from schema: Components.Schemas.ShareParticipant) { + guard let userIdentity = schema.userIdentity.map(UserIdentity.init(from:)), + let permission = schema.permission.map(SharePermission.init(from:)), + let type = schema._type.map(ShareParticipantType.init(from:)), + let acceptanceStatus = schema.acceptanceStatus.map(ShareAcceptanceStatus.init(from:)) + else { + return nil + } + self.userIdentity = userIdentity + self.permission = permission + self.type = type + self.acceptanceStatus = acceptanceStatus + } +} + +extension Components.Schemas.ShareParticipant { + internal init(from participant: ShareParticipant) { + self.init( + userIdentity: Components.Schemas.UserIdentity(from: participant.userIdentity), + permission: participant.permission.asShareParticipantPayload, + _type: participant.type.asShareParticipantPayload, + acceptanceStatus: participant.acceptanceStatus.asShareParticipantPayload + ) } } diff --git a/Sources/MistKit/Models/Sharing/ShareParticipantType.swift b/Sources/MistKit/Models/Sharing/ShareParticipantType.swift index c404bc82..41c97986 100644 --- a/Sources/MistKit/Models/Sharing/ShareParticipantType.swift +++ b/Sources/MistKit/Models/Sharing/ShareParticipantType.swift @@ -64,4 +64,14 @@ extension ShareParticipantType { case .UNKNOWN: self = .unknown } } + + internal var asShareParticipantPayload: Components.Schemas.ShareParticipant._typePayload { + switch self { + case .owner: .OWNER + case .administrator: .ADMINISTRATOR + case .user: .USER + case .publicUser: .PUBLIC_USER + case .unknown: .UNKNOWN + } + } } diff --git a/Sources/MistKit/Models/Sharing/SharePermission.swift b/Sources/MistKit/Models/Sharing/SharePermission.swift index 5ba029e4..5febde93 100644 --- a/Sources/MistKit/Models/Sharing/SharePermission.swift +++ b/Sources/MistKit/Models/Sharing/SharePermission.swift @@ -70,4 +70,30 @@ extension SharePermission { case .UNKNOWN: self = .unknown } } + + internal var asShareParticipantPayload: Components.Schemas.ShareParticipant.permissionPayload { + switch self { + case .none: .NONE + case .readOnly: .READ_ONLY + case .readWrite: .READ_WRITE + case .unknown: .UNKNOWN + } + } + + internal var asRecordRequestPublicPermissionPayload: + Components.Schemas.RecordRequest.publicPermissionPayload + { + switch self { + case .none: .NONE + case .readOnly: .READ_ONLY + case .readWrite: .READ_WRITE + case .unknown: .UNKNOWN + } + } +} + +extension Components.Schemas.RecordRequest.publicPermissionPayload { + internal init(from permission: SharePermission) { + self = permission.asRecordRequestPublicPermissionPayload + } } diff --git a/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift index 1e76fa32..670a3851 100644 --- a/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift +++ b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift @@ -34,6 +34,7 @@ internal import MistKitOpenAPI /// /// When ``ShareRecordInfo/potentialMatchList`` is non-empty the user must /// choose which invitation they are claiming before the share can be accepted. +/// ``participantId`` is required — without it there is nothing to claim. public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { /// Contact details CloudKit holds for a potential participant. public struct ContactInformation: Codable, Sendable, Equatable, Hashable { @@ -53,7 +54,7 @@ public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { } /// The identifier to send back when claiming this invitation. - public let participantId: String? + public let participantId: String /// Contact details CloudKit holds for this candidate. public let contactInformation: ContactInformation? @@ -61,13 +62,18 @@ public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { /// - Parameters: /// - participantId: The identifier of the candidate participant. /// - contactInformation: Contact details for the candidate. - public init(participantId: String? = nil, contactInformation: ContactInformation? = nil) { + public init(participantId: String, contactInformation: ContactInformation? = nil) { self.participantId = participantId self.contactInformation = contactInformation } - internal init(from schema: Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload) { - self.participantId = schema.participantId + /// Lift a potential match from the wire schema, or return `nil` when + /// `participantId` is missing. + internal init?(from schema: Components.Schemas.ShortGUIDResult.potentialMatchListPayloadPayload) { + guard let participantId = schema.participantId else { + return nil + } + self.participantId = participantId self.contactInformation = schema.contactInformation.map { ContactInformation(emailAddress: $0.emailAddress, phoneNumber: $0.phoneNumber) } diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift index 1f255788..ffb6f7b4 100644 --- a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -33,16 +33,17 @@ internal import MistKitOpenAPI /// `records/accept`. /// /// One `ShareRecordInfo` is produced per requested ``ShortGUID``, in request -/// order. Every field is optional because CloudKit populates the result -/// differently depending on the operation and on whether the caller asked for -/// the root record (``ShortGUID/shouldFetchRootRecord``). +/// order. ``shortGUID`` is always present; other fields stay optional because +/// CloudKit populates the result differently depending on the operation and +/// on whether the caller asked for the root record +/// (``ShortGUID/shouldFetchRootRecord``). /// /// When ``potentialMatchList`` is non-empty CloudKit could not identify the /// caller against a single invited participant; the user must pick which /// invitation they are claiming before the share can be accepted. public struct ShareRecordInfo: Codable, Sendable { /// The short GUID this result was resolved from. - public let shortGUID: ShortGUID? + public let shortGUID: ShortGUID /// The container holding the shared record. public let containerIdentifier: String? /// The database scope holding the shared record. @@ -56,7 +57,7 @@ public struct ShareRecordInfo: Codable, Sendable { /// The shared root record, when ``ShortGUID/shouldFetchRootRecord`` asked /// for it. public let rootRecord: RecordInfo? - /// The `cloudKit.share` record governing the share. + /// The `cloudkit.share` record governing the share. public let share: RecordInfo? /// The share-specific keys lifted out of ``share`` — participants, the /// owner, the public permission, and the caller's own participation. @@ -77,38 +78,69 @@ public struct ShareRecordInfo: Codable, Sendable { public let potentialMatchList: [SharePotentialMatch] internal init(from schema: Components.Schemas.ShortGUIDResult) throws(ConversionError) { - self.shortGUID = schema.shortGUID.map(ShortGUID.init(from:)) + guard let shortGUIDSchema = schema.shortGUID else { + try ConversionError.shareResultMissingShortGUID.reportAndThrow() + } + self.shortGUID = ShortGUID(from: shortGUIDSchema) self.containerIdentifier = schema.containerIdentifier self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) - self.environment = schema.environment.map { - switch $0 { - case .development: Environment.development - case .production: Environment.production - } - } - self.zoneID = schema.zoneID.map { - ZoneID(zoneName: $0.zoneName ?? ZoneID.defaultZone.zoneName, ownerName: $0.ownerName) - } + self.environment = schema.environment.map(Self.environment(from:)) + self.zoneID = schema.zoneID.map(Self.zoneID(from:)) self.rootRecordName = schema.rootRecordName if let rootRecord = schema.rootRecord { self.rootRecord = try RecordInfo(from: rootRecord) } else { self.rootRecord = nil } - if let share = schema.share { - self.share = try RecordInfo(from: share) - self.shareInfo = ShareInfo(from: share) - } else { - self.share = nil - self.shareInfo = nil - } + (self.share, self.shareInfo) = try Self.sharePair(from: schema.share) self.ownerIdentity = schema.ownerIdentity.map(UserIdentity.init(from:)) self.participantPermission = schema.participantPermission.map(SharePermission.init(from:)) self.participantStatus = schema.participantStatus.map(ShareAcceptanceStatus.init(from:)) self.participantType = schema.participantType.map(ShareParticipantType.init(from:)) self.webpageURL = schema.webpageURL - self.potentialMatchList = - schema.potentialMatchList?.map(SharePotentialMatch.init(from:)) ?? [] + self.potentialMatchList = try Self.potentialMatches(from: schema.potentialMatchList) + } + + private static func environment( + from payload: Components.Schemas.ShortGUIDResult.environmentPayload + ) -> Environment { + switch payload { + case .development: .development + case .production: .production + } + } + + private static func zoneID(from schema: Components.Schemas.ZoneID) -> ZoneID { + ZoneID( + zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, + ownerName: schema.ownerName + ) + } + + private static func sharePair( + from schema: Components.Schemas.RecordResponse? + ) throws(ConversionError) -> (RecordInfo?, ShareInfo?) { + guard let schema else { return (nil, nil) } + let share = try RecordInfo(from: schema) + guard let shareInfo = ShareInfo(from: schema) else { + try ConversionError.shareIncomplete.reportAndThrow() + } + return (share, shareInfo) + } + + private static func potentialMatches( + from schemas: Components.Schemas.ShortGUIDResult.potentialMatchListPayload? + ) throws(ConversionError) -> [SharePotentialMatch] { + let wireMatches = schemas ?? [] + var matches: [SharePotentialMatch] = [] + matches.reserveCapacity(wireMatches.count) + for matchSchema in wireMatches { + guard let match = SharePotentialMatch(from: matchSchema) else { + try ConversionError.sharePotentialMatchMissingParticipantId.reportAndThrow() + } + matches.append(match) + } + return matches } /// Initialize share record information. @@ -124,7 +156,7 @@ public struct ShareRecordInfo: Codable, Sendable { /// - zoneID: The zone holding the shared record. /// - rootRecordName: The name of the shared root record. /// - rootRecord: The shared root record. - /// - share: The `cloudKit.share` record governing the share. + /// - share: The `cloudkit.share` record governing the share. /// - shareInfo: The share-specific keys lifted out of `share`. /// - ownerIdentity: The identity of the share's owner. /// - participantPermission: The caller's permissions on the share. @@ -133,7 +165,7 @@ public struct ShareRecordInfo: Codable, Sendable { /// - webpageURL: The dashboard-configured fallback webpage. /// - potentialMatchList: Candidate participants to disambiguate the caller. public init( - shortGUID: ShortGUID? = nil, + shortGUID: ShortGUID, containerIdentifier: String? = nil, databaseScope: ShareDatabaseScope? = nil, environment: Environment? = nil, diff --git a/Sources/MistKit/Models/Sharing/ShareTargetReference.swift b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift new file mode 100644 index 00000000..3aeda54c --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift @@ -0,0 +1,62 @@ +// +// ShareTargetReference.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// Identifies the root record being shared when creating a `cloudKit.share` +/// record (CloudKit's `forRecord` key). +public struct ShareTargetReference: Codable, Sendable, Equatable, Hashable { + /// The record name of the shared root record. + public let recordName: String + /// Optional change tag for optimistic concurrency when creating the share. + public let recordChangeTag: String? + + /// Initialize a share target reference. + /// - Parameters: + /// - recordName: The record name of the shared root record. + /// - recordChangeTag: Optional change tag for the root record. + public init(recordName: String, recordChangeTag: String? = nil) { + self.recordName = recordName + self.recordChangeTag = recordChangeTag + } + + internal init(from schema: Components.Schemas.ShareTargetReference) { + self.recordName = schema.recordName + self.recordChangeTag = schema.recordChangeTag + } +} + +extension Components.Schemas.ShareTargetReference { + internal init(from reference: ShareTargetReference) { + self.init( + recordName: reference.recordName, + recordChangeTag: reference.recordChangeTag + ) + } +} diff --git a/Sources/MistKit/Models/Users/UserIdentity.swift b/Sources/MistKit/Models/Users/UserIdentity.swift index fc7c7df8..3b8036b0 100644 --- a/Sources/MistKit/Models/Users/UserIdentity.swift +++ b/Sources/MistKit/Models/Users/UserIdentity.swift @@ -63,3 +63,22 @@ public struct UserIdentity: Codable, Sendable { self.lookupInfo = lookupInfo } } + +extension Components.Schemas.UserIdentity { + /// Build a request-side identity. Only `userRecordName` and `lookupInfo` are + /// forwarded — name components are response-only for share invitations. + internal init(from identity: UserIdentity) { + let recordName: String? = + switch identity.userRecordName { + case .recordName(let name): name + case .nonDiscoverable: nil + } + self.init( + userRecordName: recordName, + nameComponents: nil, + lookupInfo: identity.lookupInfo.map( + Components.Schemas.UserIdentityLookupInfo.init(from:) + ) + ) + } +} diff --git a/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift index b5852ead..c6af8b44 100644 --- a/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift +++ b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift @@ -59,3 +59,13 @@ public struct UserIdentityLookupInfo: Codable, Sendable { self.userRecordName = userRecordName } } + +extension Components.Schemas.UserIdentityLookupInfo { + internal init(from lookupInfo: UserIdentityLookupInfo) { + self.init( + emailAddress: lookupInfo.emailAddress, + phoneNumber: lookupInfo.phoneNumber, + userRecordName: lookupInfo.userRecordName + ) + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift index 8ee61414..24b2643e 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.RecordOperation.swift @@ -64,7 +64,17 @@ extension Components.Schemas.RecordOperation { recordName: recordOperation.recordName, recordType: recordOperation.recordType, recordChangeTag: recordOperation.recordChangeTag, - fields: .init(additionalProperties: apiFields) + fields: .init(additionalProperties: apiFields), + createShortGUID: recordOperation.createShortGUID, + forRecord: recordOperation.forRecord.map( + Components.Schemas.ShareTargetReference.init(from:) + ), + publicPermission: recordOperation.publicPermission.map { + Components.Schemas.RecordRequest.publicPermissionPayload(from: $0) + }, + participants: recordOperation.participants?.map( + Components.Schemas.ShareParticipant.init(from:) + ) ) ) } diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift index daa6f055..5b3dca94 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift @@ -56,7 +56,7 @@ extension CloudKitServiceTests.Sharing { ) #expect(result.participantStatus == .accepted) #expect(result.participantPermission == .readWrite) - #expect(result.share?.recordType == "cloudKit.share") + #expect(result.share?.recordType == ShareInfo.recordType) let bodies = await provider.bodies(for: "acceptShares").compactMap { $0 } let body = try #require(bodies.first) @@ -82,7 +82,7 @@ extension CloudKitServiceTests.Sharing { ShortGUID(value: "guid-1"), ShortGUID(value: "guid-2"), ]) - #expect(results.map(\.shortGUID?.value) == ["guid-1", "guid-2"]) + #expect(results.map(\.shortGUID.value) == ["guid-1", "guid-2"]) } @Test("acceptShares throws on a top-level BAD_REQUEST") diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift new file mode 100644 index 00000000..5f816c53 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Create.swift @@ -0,0 +1,204 @@ +// +// CloudKitServiceTests.Sharing+Create.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.Sharing { + @Suite( + "CloudKitService createShare", + .enabled(if: Platform.isCryptoAvailable) + ) + internal struct Create { + private typealias Helper = CloudKitServiceTests.Sharing + + private static let zoneID = ZoneID(zoneName: "ShareZone") + private static let sharee = ShareParticipant( + userIdentity: UserIdentity( + lookupInfo: UserIdentityLookupInfo(emailAddress: "sharee@example.com") + ), + permission: .readWrite, + type: .user, + acceptanceStatus: .invited + ) + + @Test("createShare returns shortGUID, share URL, and root record") + internal func createShareMapsResult() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider( + responsesByOperation: [:] + ) + await provider.enqueue( + try Helper.recordsResponse([Helper.shareableRootRecord()]), + for: "modifyRecords" + ) + await provider.enqueue( + try Helper.recordsResponse([Helper.createdShareRecord()]), + for: "modifyRecords" + ) + + let created = try await service.createShare( + rootRecordType: "Note", + rootRecordName: "root-1", + rootFields: ["title": .string("Shared Note")], + zoneID: Self.zoneID, + participants: [Self.sharee], + database: .private + ) + + #expect(created.shortGUID == "guid-share-1") + #expect(created.shareURL.absoluteString == "https://www.icloud.com/share/guid-share-1") + #expect(created.rootRecord.recordName == "root-1") + #expect(created.rootRecord.recordType == "Note") + #expect(created.shareRecordName == "share-1") + #expect(created.share.shortGUID == "guid-share-1") + #expect(created.share.publicPermission == SharePermission.none) + #expect(created.share.participants.count == 2) + #expect(created.share.owner.type == .owner) + #expect(created.share.currentUserParticipant.type == .owner) + } + + @Test("createShare creates root then atomic cloudkit.share with change tag") + internal func createShareSerializesShareCreateBody() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider( + responsesByOperation: [:] + ) + await provider.enqueue( + try Helper.recordsResponse([Helper.shareableRootRecord(changeTag: "tag-1")]), + for: "modifyRecords" + ) + await provider.enqueue( + try Helper.recordsResponse([Helper.createdShareRecord()]), + for: "modifyRecords" + ) + + _ = try await service.createShare( + rootRecordType: "Note", + rootRecordName: "root-1", + rootFields: ["title": .string("Shared Note")], + zoneID: Self.zoneID, + publicPermission: .none, + participants: [Self.sharee], + database: .private + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + #expect(bodies.count == 2) + try Self.expectRootCreateBody(bodies[0]) + try Self.expectShareCreateBody(bodies[1]) + } + + @Test("createShare throws when the share response is incomplete") + internal func createShareRequiresCompleteShareInfo() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + var shareWithoutGUID = Helper.createdShareRecord() + shareWithoutGUID.removeValue(forKey: "shortGUID") + var rootWithoutGUID = Helper.shareableRootRecord() + rootWithoutGUID.removeValue(forKey: "shortGUID") + let (service, provider) = try Helper.makeServiceWithProvider( + responsesByOperation: [:] + ) + await provider.enqueue( + try Helper.recordsResponse([rootWithoutGUID]), + for: "modifyRecords" + ) + await provider.enqueue( + try Helper.recordsResponse([shareWithoutGUID]), + for: "modifyRecords" + ) + + await ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + await #expect(throws: CloudKitError.self) { + _ = try await service.createShare( + rootRecordType: "Note", + rootRecordName: "root-1", + zoneID: Self.zoneID, + participants: [Self.sharee], + database: .private + ) + } + } + ) + } + + private static func expectRootCreateBody(_ body: Data) throws { + let rootJSON = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + #expect(rootJSON["atomic"] as? Bool == false) + let rootZone = try #require(rootJSON["zoneID"] as? [String: Any]) + #expect(rootZone["zoneName"] as? String == "ShareZone") + let rootOps = try #require(rootJSON["operations"] as? [[String: Any]]) + #expect(rootOps.count == 1) + let rootRecord = try #require(rootOps[0]["record"] as? [String: Any]) + #expect(rootRecord["createShortGUID"] as? Bool == true) + #expect(rootRecord["recordType"] as? String == "Note") + #expect(rootRecord["recordName"] as? String == "root-1") + #expect(rootRecord["forRecord"] == nil) + } + + private static func expectShareCreateBody(_ body: Data) throws { + let shareJSON = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + #expect(shareJSON["atomic"] as? Bool == true) + let shareOps = try #require(shareJSON["operations"] as? [[String: Any]]) + #expect(shareOps.count == 1) + let shareRecord = try #require(shareOps[0]["record"] as? [String: Any]) + #expect(shareRecord["recordType"] as? String == ShareInfo.recordType) + #expect(shareRecord["publicPermission"] as? String == "NONE") + let forRecord = try #require(shareRecord["forRecord"] as? [String: Any]) + #expect(forRecord["recordName"] as? String == "root-1") + #expect(forRecord["recordChangeTag"] as? String == "tag-1") + let participants = try #require(shareRecord["participants"] as? [[String: Any]]) + #expect(participants.count == 1) + #expect(participants[0]["permission"] as? String == "READ_WRITE") + #expect(participants[0]["type"] as? String == "USER") + #expect(participants[0]["acceptanceStatus"] as? String == "INVITED") + let identity = try #require(participants[0]["userIdentity"] as? [String: Any]) + let lookup = try #require(identity["lookupInfo"] as? [String: Any]) + #expect(lookup["emailAddress"] as? String == "sharee@example.com") + #expect(shareRecord["createShortGUID"] == nil) + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift index a20f00d0..cc3ea470 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift @@ -111,23 +111,24 @@ extension CloudKitServiceTests.Sharing { return result } - /// A `cloudKit.share` record dictionary carrying share response keys. + /// A `cloudkit.share` record dictionary carrying share response keys. internal static func shareRecord(for value: String) -> [String: Any] { - [ + let owner: [String: Any] = [ + "permission": "READ_WRITE", + "type": "OWNER", + "acceptanceStatus": "ACCEPTED", + "userIdentity": ["userRecordName": "_owner"], + ] + return [ "recordName": "share-\(value)", - "recordType": "cloudKit.share", + "recordType": ShareInfo.recordType, "recordChangeTag": "share-tag-1", "fields": [:], "shortGUID": value, "publicPermission": "READ_ONLY", - "participants": [ - [ - "permission": "READ_WRITE", - "type": "OWNER", - "acceptanceStatus": "ACCEPTED", - "userIdentity": ["userRecordName": "_owner"], - ] - ], + "participants": [owner], + "owner": owner, + "currentUserParticipant": owner, ] } @@ -149,6 +150,60 @@ extension CloudKitServiceTests.Sharing { ] } + /// A `records/modify` 200 body wrapping the given records. + internal static func recordsResponse(_ records: [[String: Any]]) throws -> ResponseConfig { + try jsonResponse(["records": records]) + } + + /// A root record created with a short GUID for share tests. + internal static func shareableRootRecord( + recordName: String = "root-1", + changeTag: String = "tag-1", + shortGUID: String = "guid-share-1" + ) -> [String: Any] { + [ + "recordName": recordName, + "recordType": "Note", + "recordChangeTag": changeTag, + "fields": ["title": ["value": "Shared Note", "type": "STRING"]], + "shortGUID": shortGUID, + ] + } + + /// A `cloudkit.share` record response carrying share-create keys. + internal static func createdShareRecord( + recordName: String = "share-1", + changeTag: String = "share-tag-1", + shortGUID: String = "guid-share-1" + ) -> [String: Any] { + let owner: [String: Any] = [ + "permission": "READ_WRITE", + "type": "OWNER", + "acceptanceStatus": "ACCEPTED", + "userIdentity": ["userRecordName": "_owner"], + ] + let invitee: [String: Any] = [ + "permission": "READ_WRITE", + "type": "USER", + "acceptanceStatus": "INVITED", + "userIdentity": [ + "lookupInfo": ["emailAddress": "sharee@example.com"] + ], + ] + return [ + "recordName": recordName, + "recordType": ShareInfo.recordType, + "recordChangeTag": changeTag, + "fields": [:], + "shortGUID": shortGUID, + "share": ["recordName": recordName], + "publicPermission": "NONE", + "participants": [owner, invitee], + "owner": owner, + "currentUserParticipant": owner, + ] + } + private static func jsonResponse(_ object: [String: Any]) throws -> ResponseConfig { let body = try JSONSerialization.data(withJSONObject: object) var headers = HTTPFields() diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift index 1dfba889..7ce138f5 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift @@ -55,8 +55,8 @@ extension CloudKitServiceTests.Sharing { #expect(results.count == 1) let result = try #require(results.first) - #expect(result.shortGUID?.value == "guid-1") - #expect(result.shortGUID?.shouldFetchRootRecord == true) + #expect(result.shortGUID.value == "guid-1") + #expect(result.shortGUID.shouldFetchRootRecord == true) #expect(result.containerIdentifier == TestConstants.serviceContainerIdentifier) #expect(result.databaseScope == .shared) #expect(result.environment == .development) @@ -65,7 +65,7 @@ extension CloudKitServiceTests.Sharing { #expect(result.rootRecordName == "root-guid-1") #expect(result.rootRecord?.recordName == "root-guid-1") #expect(result.rootRecord?.recordType == "Note") - #expect(result.share?.recordType == "cloudKit.share") + #expect(result.share?.recordType == ShareInfo.recordType) #expect(result.ownerIdentity?.userRecordName == .recordName("_owner")) #expect(result.participantPermission == .readWrite) #expect(result.participantStatus == .accepted) @@ -96,7 +96,7 @@ extension CloudKitServiceTests.Sharing { ShortGUID(value: "guid-2"), ]) - #expect(results.map(\.shortGUID?.value) == ["guid-1", "guid-2"]) + #expect(results.map(\.shortGUID.value) == ["guid-1", "guid-2"]) let bodies = await provider.bodies(for: "resolveShortGUIDs").compactMap { $0 } let body = try #require(bodies.first) diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift index 5fef6232..45e0456f 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift @@ -39,7 +39,7 @@ extension CloudKitServiceTests.Sharing { internal struct ShareInfoMapping { private typealias Helper = CloudKitServiceTests.Sharing - @Test("resolveShares lifts share keys off the cloudKit.share record") + @Test("resolveShares lifts share keys off the cloudkit.share record") internal func resolveLiftsShareInfo() async throws { guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { Issue.record("CloudKitService is not available on this operating system.") @@ -62,34 +62,37 @@ extension CloudKitServiceTests.Sharing { #expect(participant.permission == .readWrite) #expect(participant.type == .owner) #expect(participant.acceptanceStatus == .accepted) - #expect(participant.userIdentity?.userRecordName == .recordName("_owner")) + #expect(participant.userIdentity.userRecordName == .recordName("_owner")) } - @Test("shareInfo is nil for a record carrying no share keys") - internal func shareInfoNilForPlainRecord() async throws { + @Test("incomplete share record fails conversion with shareIncomplete") + internal func incompleteShareThrows() async throws { guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { Issue.record("CloudKitService is not available on this operating system.") return } - // A `share` entry that is a plain record dictionary — no share keys. + // A `share` entry that looks like a share type but omits required keys. let service = try Helper.makeService(responsesByOperation: [ "resolveShortGUIDs": try Helper.shortGUIDResponse(results: [ [ "shortGUID": ["value": "guid-1"], "share": [ "recordName": "share-guid-1", - "recordType": "cloudKit.share", + "recordType": ShareInfo.recordType, "fields": [:], ], ] ]) ]) - let result = try #require( - try await service.resolveShares([ShortGUID(value: "guid-1")]).first + await ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + await #expect(throws: CloudKitError.self) { + _ = try await service.resolveShares([ShortGUID(value: "guid-1")]) + } + } ) - #expect(result.share?.recordName == "share-guid-1") - #expect(result.shareInfo == nil) } } } diff --git a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift index fec1e7dd..579a2f29 100644 --- a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift +++ b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift @@ -34,6 +34,13 @@ internal import Testing @Suite("Sharing Models") internal struct ShareModelTests { + private static let owner = ShareParticipant( + userIdentity: UserIdentity(userRecordName: .recordName("_owner")), + permission: .readWrite, + type: .owner, + acceptanceStatus: .accepted + ) + @Test("ShortGUID round-trips through Codable") internal func shortGUIDRoundTrips() throws { let original = ShortGUID( @@ -110,16 +117,17 @@ internal struct ShareModelTests { #expect(expected.rawValue == raw) } - @Test("ShareRecordInfo defaults every field when constructed empty") + @Test("ShareRecordInfo requires shortGUID and defaults the rest") internal func shareRecordInfoDefaults() { - let info = ShareRecordInfo() - #expect(info.shortGUID == nil) + let info = ShareRecordInfo(shortGUID: ShortGUID(value: "guid-1")) + #expect(info.shortGUID.value == "guid-1") #expect(info.containerIdentifier == nil) #expect(info.databaseScope == nil) #expect(info.environment == nil) #expect(info.zoneID == nil) #expect(info.rootRecord == nil) #expect(info.share == nil) + #expect(info.shareInfo == nil) #expect(info.ownerIdentity == nil) #expect(info.participantPermission == nil) #expect(info.participantStatus == nil) @@ -134,27 +142,40 @@ internal struct ShareModelTests { participantId: "c1", contactInformation: .init(emailAddress: "a@example.com") ) + #expect(emailOnly.participantId == "c1") #expect(emailOnly.contactInformation?.emailAddress == "a@example.com") #expect(emailOnly.contactInformation?.phoneNumber == nil) } - @Test("ShareInfo defaults every field when constructed empty") - internal func shareInfoDefaults() { - let info = ShareInfo() - #expect(info.shortGUID == nil) - #expect(info.sharedRecordName == nil) - #expect(info.publicPermission == nil) - #expect(info.participants.isEmpty) - #expect(info.owner == nil) - #expect(info.currentUserParticipant == nil) + @Test("ShareInfo requires the share key set") + internal func shareInfoRequiresKeys() { + let info = ShareInfo( + shortGUID: "guid-1", + publicPermission: .none, + participants: [Self.owner], + owner: Self.owner, + currentUserParticipant: Self.owner + ) + #expect(info.shortGUID == "guid-1") + #expect(info.publicPermission == .none) + #expect(info.participants.count == 1) + #expect(info.owner.type == .owner) + #expect(info.currentUserParticipant.type == .owner) } - @Test("ShareParticipant defaults every field to nil") - internal func shareParticipantDefaults() { - let participant = ShareParticipant() - #expect(participant.userIdentity == nil) - #expect(participant.permission == nil) - #expect(participant.type == nil) - #expect(participant.acceptanceStatus == nil) + @Test("ShareParticipant requires identity, permission, type, and status") + internal func shareParticipantRequiresFields() { + let participant = ShareParticipant( + userIdentity: UserIdentity( + lookupInfo: UserIdentityLookupInfo(emailAddress: "a@example.com") + ), + permission: .readWrite, + type: .user, + acceptanceStatus: .invited + ) + #expect(participant.userIdentity.userRecordName == .nonDiscoverable) + #expect(participant.permission == .readWrite) + #expect(participant.type == .user) + #expect(participant.acceptanceStatus == .invited) } } From 44d31c459b4ddc60c6fe300b23665233a3e476cd Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 17:12:16 -0400 Subject: [PATCH 4/5] Refine share models from PR review: compose ContactInformation, ShortGUID.Value, and typed conversions. Lift shared contact details, replace conversion tuples/helpers with proper types and inits, and build invite URLs from a reusable base. Co-authored-by: Cursor --- .../MistKit/Models/ContactInformation.swift | 50 +++++++++++++ Sources/MistKit/Models/Environment.swift | 9 +++ .../MistKit/Models/Sharing/CreatedShare.swift | 18 +++-- .../MistKit/Models/Sharing/ShareInfo.swift | 4 +- .../Models/Sharing/SharePotentialMatch.swift | 38 +++++----- .../Models/Sharing/ShareRecordInfo.swift | 57 +++------------ .../Models/Sharing/ShareRecordPair.swift | 63 ++++++++++++++++ .../MistKit/Models/Sharing/ShortGUID.swift | 7 +- .../Models/Users/UserIdentityLookupInfo.swift | 72 ++++++++++++++++--- Sources/MistKit/Models/Zones/ZoneID.swift | 11 +++ .../Models/Sharing/ShareModelTests.swift | 34 ++++++++- 11 files changed, 280 insertions(+), 83 deletions(-) create mode 100644 Sources/MistKit/Models/ContactInformation.swift create mode 100644 Sources/MistKit/Models/Sharing/ShareRecordPair.swift diff --git a/Sources/MistKit/Models/ContactInformation.swift b/Sources/MistKit/Models/ContactInformation.swift new file mode 100644 index 00000000..99483fee --- /dev/null +++ b/Sources/MistKit/Models/ContactInformation.swift @@ -0,0 +1,50 @@ +// +// ContactInformation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Contact details CloudKit associates with a person — email and/or phone. +/// +/// Used by share potential-match disambiguation (``SharePotentialMatch``) and +/// composed into ``UserIdentityLookupInfo`` (which also carries a user record +/// name). On the wire, lookup info stays flat; share potential matches nest +/// these keys under `contactInformation`. +public struct ContactInformation: Codable, Sendable, Equatable, Hashable { + /// The email address, when known. + public let emailAddress: String? + /// The phone number, when known. + public let phoneNumber: String? + + /// Initialize contact information. + /// - Parameters: + /// - emailAddress: The email address. + /// - phoneNumber: The phone number. + public init(emailAddress: String? = nil, phoneNumber: String? = nil) { + self.emailAddress = emailAddress + self.phoneNumber = phoneNumber + } +} diff --git a/Sources/MistKit/Models/Environment.swift b/Sources/MistKit/Models/Environment.swift index 1cf42d18..03680c07 100644 --- a/Sources/MistKit/Models/Environment.swift +++ b/Sources/MistKit/Models/Environment.swift @@ -28,6 +28,7 @@ // internal import Foundation +internal import MistKitOpenAPI /// CloudKit environment types public enum Environment: String, Codable, Sendable { @@ -40,4 +41,12 @@ public enum Environment: String, Codable, Sendable { public init?(caseInsensitive raw: String) { self.init(rawValue: raw.lowercased()) } + + /// Lift an environment from a ShortGUID Result payload. + internal init(from payload: Components.Schemas.ShortGUIDResult.environmentPayload) { + switch payload { + case .development: self = .development + case .production: self = .production + } + } } diff --git a/Sources/MistKit/Models/Sharing/CreatedShare.swift b/Sources/MistKit/Models/Sharing/CreatedShare.swift index 9cb55ec4..dd64971e 100644 --- a/Sources/MistKit/Models/Sharing/CreatedShare.swift +++ b/Sources/MistKit/Models/Sharing/CreatedShare.swift @@ -35,8 +35,16 @@ public import Foundation /// and share-specific metadata. Share keys stay here (and on ``ShareInfo``) /// rather than on ``RecordInfo``, which models a plain record. public struct CreatedShare: Sendable { + // swiftlint:disable force_unwrapping + // swift-format-ignore: NeverForceUnwrap + /// Base URL for iCloud share invite links (`https://www.icloud.com/share`). + /// + /// Append a ``ShortGUID/Value`` path component to build a full invite URL. + public static let shareURLBase = URL(string: "https://www.icloud.com/share")! + // swiftlint:enable force_unwrapping + /// The short GUID CloudKit assigned to the share (and shared root). - public let shortGUID: String + public let shortGUID: ShortGUID.Value /// The iCloud share invite URL (`https://www.icloud.com/share/{shortGUID}`). public let shareURL: URL /// Share-specific keys lifted from the `cloudkit.share` record. @@ -54,7 +62,7 @@ public struct CreatedShare: Sendable { /// - rootRecord: The root record that was shared. /// - shareRecordName: The `cloudkit.share` record name. public init( - shortGUID: String, + shortGUID: ShortGUID.Value, shareURL: URL, share: ShareInfo, rootRecord: RecordInfo, @@ -68,9 +76,7 @@ public struct CreatedShare: Sendable { } /// Build the standard iCloud share invite URL for a short GUID value. - public static func shareURL(forShortGUID shortGUID: String) -> URL { - // swiftlint:disable:next force_unwrapping - // swift-format-ignore: NeverForceUnwrap - URL(string: "https://www.icloud.com/share/\(shortGUID)")! + public static func shareURL(forShortGUID shortGUID: ShortGUID.Value) -> URL { + shareURLBase.appendingPathComponent(shortGUID) } } diff --git a/Sources/MistKit/Models/Sharing/ShareInfo.swift b/Sources/MistKit/Models/Sharing/ShareInfo.swift index 719bb220..1ade9a82 100644 --- a/Sources/MistKit/Models/Sharing/ShareInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareInfo.swift @@ -51,7 +51,7 @@ public struct ShareInfo: Codable, Sendable { public static let recordType = "cloudkit.share" /// The short GUID identifying this share. - public let shortGUID: String + public let shortGUID: ShortGUID.Value /// The public's read and write permissions on the shared record. public let publicPermission: SharePermission /// The participants in the share. @@ -69,7 +69,7 @@ public struct ShareInfo: Codable, Sendable { /// - owner: The owner of the shared record. /// - currentUserParticipant: The current user's participation. public init( - shortGUID: String, + shortGUID: ShortGUID.Value, publicPermission: SharePermission, participants: [ShareParticipant] = [], owner: ShareParticipant, diff --git a/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift index 670a3851..f71c1154 100644 --- a/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift +++ b/Sources/MistKit/Models/Sharing/SharePotentialMatch.swift @@ -36,23 +36,6 @@ internal import MistKitOpenAPI /// choose which invitation they are claiming before the share can be accepted. /// ``participantId`` is required — without it there is nothing to claim. public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { - /// Contact details CloudKit holds for a potential participant. - public struct ContactInformation: Codable, Sendable, Equatable, Hashable { - /// The candidate's email address, when known. - public let emailAddress: String? - /// The candidate's phone number, when known. - public let phoneNumber: String? - - /// Initialize contact information. - /// - Parameters: - /// - emailAddress: The candidate's email address. - /// - phoneNumber: The candidate's phone number. - public init(emailAddress: String? = nil, phoneNumber: String? = nil) { - self.emailAddress = emailAddress - self.phoneNumber = phoneNumber - } - } - /// The identifier to send back when claiming this invitation. public let participantId: String /// Contact details CloudKit holds for this candidate. @@ -79,3 +62,24 @@ public struct SharePotentialMatch: Codable, Sendable, Equatable, Hashable { } } } + +extension [SharePotentialMatch] { + /// Lift a potential-match list from the wire schema. + /// + /// - Parameter schemas: The optional wire list (treated as empty when `nil`). + /// - Throws: ``ConversionError/sharePotentialMatchMissingParticipantId`` when + /// any entry omits `participantId`. + internal init( + from schemas: Components.Schemas.ShortGUIDResult.potentialMatchListPayload? + ) throws(ConversionError) { + let wireMatches = schemas ?? [] + var matches: [SharePotentialMatch] = [] + for matchSchema in wireMatches { + guard let match = SharePotentialMatch(from: matchSchema) else { + try ConversionError.sharePotentialMatchMissingParticipantId.reportAndThrow() + } + matches.append(match) + } + self = matches + } +} diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift index ffb6f7b4..e1f0a74b 100644 --- a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -84,63 +84,28 @@ public struct ShareRecordInfo: Codable, Sendable { self.shortGUID = ShortGUID(from: shortGUIDSchema) self.containerIdentifier = schema.containerIdentifier self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) - self.environment = schema.environment.map(Self.environment(from:)) - self.zoneID = schema.zoneID.map(Self.zoneID(from:)) + self.environment = schema.environment.map(Environment.init(from:)) + self.zoneID = schema.zoneID.map(ZoneID.init(from:)) self.rootRecordName = schema.rootRecordName if let rootRecord = schema.rootRecord { self.rootRecord = try RecordInfo(from: rootRecord) } else { self.rootRecord = nil } - (self.share, self.shareInfo) = try Self.sharePair(from: schema.share) + if let shareSchema = schema.share { + let pair = try ShareRecordPair(from: shareSchema) + self.share = pair.record + self.shareInfo = pair.info + } else { + self.share = nil + self.shareInfo = nil + } self.ownerIdentity = schema.ownerIdentity.map(UserIdentity.init(from:)) self.participantPermission = schema.participantPermission.map(SharePermission.init(from:)) self.participantStatus = schema.participantStatus.map(ShareAcceptanceStatus.init(from:)) self.participantType = schema.participantType.map(ShareParticipantType.init(from:)) self.webpageURL = schema.webpageURL - self.potentialMatchList = try Self.potentialMatches(from: schema.potentialMatchList) - } - - private static func environment( - from payload: Components.Schemas.ShortGUIDResult.environmentPayload - ) -> Environment { - switch payload { - case .development: .development - case .production: .production - } - } - - private static func zoneID(from schema: Components.Schemas.ZoneID) -> ZoneID { - ZoneID( - zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, - ownerName: schema.ownerName - ) - } - - private static func sharePair( - from schema: Components.Schemas.RecordResponse? - ) throws(ConversionError) -> (RecordInfo?, ShareInfo?) { - guard let schema else { return (nil, nil) } - let share = try RecordInfo(from: schema) - guard let shareInfo = ShareInfo(from: schema) else { - try ConversionError.shareIncomplete.reportAndThrow() - } - return (share, shareInfo) - } - - private static func potentialMatches( - from schemas: Components.Schemas.ShortGUIDResult.potentialMatchListPayload? - ) throws(ConversionError) -> [SharePotentialMatch] { - let wireMatches = schemas ?? [] - var matches: [SharePotentialMatch] = [] - matches.reserveCapacity(wireMatches.count) - for matchSchema in wireMatches { - guard let match = SharePotentialMatch(from: matchSchema) else { - try ConversionError.sharePotentialMatchMissingParticipantId.reportAndThrow() - } - matches.append(match) - } - return matches + self.potentialMatchList = try [SharePotentialMatch](from: schema.potentialMatchList) } /// Initialize share record information. diff --git a/Sources/MistKit/Models/Sharing/ShareRecordPair.swift b/Sources/MistKit/Models/Sharing/ShareRecordPair.swift new file mode 100644 index 00000000..3ba333f7 --- /dev/null +++ b/Sources/MistKit/Models/Sharing/ShareRecordPair.swift @@ -0,0 +1,63 @@ +// +// ShareRecordPair.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI + +/// A `cloudkit.share` record together with its share-specific keys. +/// +/// ``record`` is the ordinary record dictionary; ``info`` is the share +/// metadata lifted by ``ShareInfo``. Both are required — a share payload +/// that cannot produce ``ShareInfo`` is ``ConversionError/shareIncomplete``. +public struct ShareRecordPair: Sendable { + /// The `cloudkit.share` record as a plain ``RecordInfo``. + public let record: RecordInfo + /// Share-specific keys lifted from the same wire payload. + public let info: ShareInfo + + /// Initialize a share record pair. + /// - Parameters: + /// - record: The `cloudkit.share` record. + /// - info: Share-specific keys from that record. + public init(record: RecordInfo, info: ShareInfo) { + self.record = record + self.info = info + } + + /// Lift a share record and its share keys from a record response. + /// - Parameter schema: The wire `cloudkit.share` record. + /// - Throws: ``ConversionError`` when the record cannot be converted or + /// the share key set is incomplete. + internal init(from schema: Components.Schemas.RecordResponse) throws(ConversionError) { + self.record = try RecordInfo(from: schema) + guard let info = ShareInfo(from: schema) else { + try ConversionError.shareIncomplete.reportAndThrow() + } + self.info = info + } +} diff --git a/Sources/MistKit/Models/Sharing/ShortGUID.swift b/Sources/MistKit/Models/Sharing/ShortGUID.swift index 7418f9fc..6afdb8d4 100644 --- a/Sources/MistKit/Models/Sharing/ShortGUID.swift +++ b/Sources/MistKit/Models/Sharing/ShortGUID.swift @@ -38,8 +38,11 @@ internal import MistKitOpenAPI /// (``CloudKitService/resolveShares(_:)``) and accept /// (``CloudKitService/acceptShares(_:)``) a share. public struct ShortGUID: Codable, Sendable, Equatable, Hashable { + /// Opaque short-GUID token string as returned by CloudKit. + public typealias Value = String + /// The value of the short global ID. - public let value: String + public let value: Value /// Whether the root record should be fetched alongside the share. /// /// When `nil`, CloudKit applies its own default. @@ -56,7 +59,7 @@ public struct ShortGUID: Codable, Sendable, Equatable, Hashable { /// the share. /// - rootRecordDesiredKeys: Field names limiting the root record payload. public init( - value: String, + value: Value, shouldFetchRootRecord: Bool? = nil, rootRecordDesiredKeys: [String]? = nil ) { diff --git a/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift index c6af8b44..531aef10 100644 --- a/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift +++ b/Sources/MistKit/Models/Users/UserIdentityLookupInfo.swift @@ -27,21 +27,50 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import Foundation internal import MistKitOpenAPI /// Information used to look up a user identity from CloudKit. +/// +/// Composes ``ContactInformation`` (email / phone) with an optional user +/// record name. Codable keeps the CloudKit wire shape flat — +/// `emailAddress`, `phoneNumber`, and `userRecordName` at the top level — +/// rather than nesting contact fields. public struct UserIdentityLookupInfo: Codable, Sendable { - /// The email address to look up - public let emailAddress: String? - /// The phone number to look up - public let phoneNumber: String? + private enum CodingKeys: String, CodingKey { + case emailAddress + case phoneNumber + case userRecordName + } + + /// Contact details used to look up the user. + public let contactInformation: ContactInformation? /// The user record name to look up public let userRecordName: String? + /// The email address to look up + public var emailAddress: String? { contactInformation?.emailAddress } + /// The phone number to look up + public var phoneNumber: String? { contactInformation?.phoneNumber } + internal init(from schema: Components.Schemas.UserIdentityLookupInfo) { - self.emailAddress = schema.emailAddress - self.phoneNumber = schema.phoneNumber - self.userRecordName = schema.userRecordName + self.init( + emailAddress: schema.emailAddress, + phoneNumber: schema.phoneNumber, + userRecordName: schema.userRecordName + ) + } + + /// Initialize lookup info from contact details and an optional record name. + /// - Parameters: + /// - contactInformation: Email and/or phone for the lookup. + /// - userRecordName: The user record name to look up. + public init( + contactInformation: ContactInformation?, + userRecordName: String? = nil + ) { + self.contactInformation = contactInformation + self.userRecordName = userRecordName } /// Initialize lookup info with optional identifiers @@ -54,10 +83,35 @@ public struct UserIdentityLookupInfo: Codable, Sendable { phoneNumber: String? = nil, userRecordName: String? = nil ) { - self.emailAddress = emailAddress - self.phoneNumber = phoneNumber + if emailAddress != nil || phoneNumber != nil { + self.contactInformation = ContactInformation( + emailAddress: emailAddress, + phoneNumber: phoneNumber + ) + } else { + self.contactInformation = nil + } self.userRecordName = userRecordName } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let emailAddress = try container.decodeIfPresent(String.self, forKey: .emailAddress) + let phoneNumber = try container.decodeIfPresent(String.self, forKey: .phoneNumber) + let userRecordName = try container.decodeIfPresent(String.self, forKey: .userRecordName) + self.init( + emailAddress: emailAddress, + phoneNumber: phoneNumber, + userRecordName: userRecordName + ) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(emailAddress, forKey: .emailAddress) + try container.encodeIfPresent(phoneNumber, forKey: .phoneNumber) + try container.encodeIfPresent(userRecordName, forKey: .userRecordName) + } } extension Components.Schemas.UserIdentityLookupInfo { diff --git a/Sources/MistKit/Models/Zones/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift index cd2b4b9e..9b503333 100644 --- a/Sources/MistKit/Models/Zones/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -51,6 +51,17 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { self.zoneName = zoneName self.ownerName = ownerName } + + /// Lift a zone identifier from the wire schema. + /// + /// A missing `zoneName` falls back to ``defaultZone``'s name — share + /// results may omit it when CloudKit only returns an owner. + internal init(from schema: Components.Schemas.ZoneID) { + self.init( + zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, + ownerName: schema.ownerName + ) + } } // MARK: - Internal Conversion diff --git a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift index 579a2f29..d72e0386 100644 --- a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift +++ b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift @@ -140,13 +140,45 @@ internal struct ShareModelTests { internal func potentialMatchPartialContact() { let emailOnly = SharePotentialMatch( participantId: "c1", - contactInformation: .init(emailAddress: "a@example.com") + contactInformation: ContactInformation(emailAddress: "a@example.com") ) #expect(emailOnly.participantId == "c1") #expect(emailOnly.contactInformation?.emailAddress == "a@example.com") #expect(emailOnly.contactInformation?.phoneNumber == nil) } + @Test("CreatedShare builds invite URLs from shareURLBase") + internal func createdShareURLUsesBase() { + let url = CreatedShare.shareURL(forShortGUID: "guid-1") + #expect(url.absoluteString == "https://www.icloud.com/share/guid-1") + #expect(url.absoluteString.hasPrefix(CreatedShare.shareURLBase.absoluteString)) + } + + @Test("UserIdentityLookupInfo Codable stays flat on the wire") + internal func lookupInfoCodableIsFlat() throws { + let original = UserIdentityLookupInfo( + contactInformation: ContactInformation( + emailAddress: "a@example.com", + phoneNumber: "+15550100" + ), + userRecordName: "_user-1" + ) + let data = try JSONEncoder().encode(original) + let json = try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + #expect(json["emailAddress"] as? String == "a@example.com") + #expect(json["phoneNumber"] as? String == "+15550100") + #expect(json["userRecordName"] as? String == "_user-1") + #expect(json["contactInformation"] == nil) + + let decoded = try JSONDecoder().decode(UserIdentityLookupInfo.self, from: data) + #expect(decoded.emailAddress == "a@example.com") + #expect(decoded.phoneNumber == "+15550100") + #expect(decoded.userRecordName == "_user-1") + #expect(decoded.contactInformation?.emailAddress == "a@example.com") + } + @Test("ShareInfo requires the share key set") internal func shareInfoRequiresKeys() { let info = ShareInfo( From 550af0448bedb0492cbe45f93e30fe4c12b0a694 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 27 Aug 2026 18:08:49 -0400 Subject: [PATCH 5/5] Rename ShortGUID token vs dictionary for clearer share APIs. [skip ci] Make ShortGUID a String typealias and rename the resolve/accept dictionary shape to ShortGUIDDictionary. Co-authored-by: Cursor --- AGENTS.md | 6 +-- .../MistDemoKit/Commands/AcceptCommand.swift | 2 +- .../MistDemoKit/Commands/ResolveCommand.swift | 2 +- .../Phases/AcceptSharesPhase.swift | 2 +- .../Phases/ResolveRecordsPhase.swift | 2 +- .../Phases/ShareCreateAndAcceptPhase.swift | 2 +- .../CloudKitService+WebBackend+Shares.swift | 4 +- .../Server/MockBackend+ShareOperations.swift | 4 +- .../CloudKitService+ShareOperations.swift | 8 ++-- .../MistKit/Models/Sharing/CreatedShare.swift | 8 ++-- .../MistKit/Models/Sharing/ShareInfo.swift | 4 +- .../Models/Sharing/ShareRecordInfo.swift | 20 +++++----- ...rtGUID.swift => ShortGUIDDictionary.swift} | 37 ++++++++++--------- .../CloudKitServiceTests.Sharing+Accept.swift | 8 ++-- ...CloudKitServiceTests.Sharing+Resolve.swift | 14 +++---- ...oudKitServiceTests.Sharing+ShareInfo.swift | 4 +- .../Models/Sharing/ShareModelTests.swift | 16 ++++---- 17 files changed, 73 insertions(+), 70 deletions(-) rename Sources/MistKit/Models/Sharing/{ShortGUID.swift => ShortGUIDDictionary.swift} (72%) diff --git a/AGENTS.md b/AGENTS.md index f73903b7..c97b0d41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,12 +219,12 @@ MistKit/ **Share Operations (issues #41 / #42 / #437 — create needs private custom zone + web-auth; resolve/accept are public DB + web-auth):** - `createShare(...)` → `records/modify` — creates a root (`createShortGUID`) plus `cloudkit.share`, returns ``CreatedShare`` (`shortGUID`, share URL, ``ShareInfo``, root ``RecordInfo``). -- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUID]` into `[ShareRecordInfo]` (root record, `cloudkit.share` record, owner identity, the caller's participation). -- `acceptShares(_:)` → POST `/records/accept` — accepts `[ShortGUID]` on behalf of the current user; returns the same `[ShareRecordInfo]` shape reporting the caller's resulting participation. +- `resolveShares(_:)` → POST `/records/resolve` — resolves `[ShortGUIDDictionary]` into `[ShareRecordInfo]` (root record, `cloudkit.share` record, owner identity, the caller's participation). +- `acceptShares(_:)` → POST `/records/accept` — accepts `[ShortGUIDDictionary]` on behalf of the current user; returns the same `[ShareRecordInfo]` shape reporting the caller's resulting participation. `createShare` writes against the caller's `database:` (typically `.private`) in a custom `zoneID`. Resolve/accept are documented **only** in Apple's archived CloudKit Web Services Reference (`FetchingRecordInformation` / `AcceptingShareRecords`), which fixes the path's database scope to `public`; they act on behalf of the *current* user, so — like `fetchCaller()` — they hard-code `.public(.requires(.webAuth))` and expose **no** `database:` parameter. Both validate the request as a whole: a bad short GUID fails the entire call rather than producing a per-item failure, so there is no `RecordResult`-style failure variant. -Set `ShortGUID.shouldFetchRootRecord` to have CloudKit include the shared root record, optionally narrowed by `rootRecordDesiredKeys`. When CloudKit cannot match the caller to exactly one invited participant, `ShareRecordInfo.potentialMatchList` is non-empty and the user must choose which invitation they are claiming. Domain models live in `Sources/MistKit/Models/Sharing/`. +Set `ShortGUIDDictionary.shouldFetchRootRecord` to have CloudKit include the shared root record, optionally narrowed by `rootRecordDesiredKeys`. When CloudKit cannot match the caller to exactly one invited participant, `ShareRecordInfo.potentialMatchList` is non-empty and the user must choose which invitation they are claiming. Domain models live in `Sources/MistKit/Models/Sharing/`. The opaque token string is `ShortGUID`; the resolve/accept dictionary shape is `ShortGUIDDictionary`. **Batch chunking (issue #307):** the two non-deprecated operations capped at CloudKit's 200-item-per-request limit (`CloudKitService.maxRecordsPerRequest`) each pair a single-request primitive with an auto-chunking convenience that splits the input into ≤`batchSize` batches, calls the primitive per batch, and concatenates results in input order. This mirrors the `queryRecords`/`queryAllRecords` page-primitive + auto-paginating-extension pattern. Because chunk count is `ceil(input.count / batchSize)` — deterministic and finite — there is **no** `maxPages`-style throwing ceiling; `batchSize` (default `maxRecordsPerRequest`, clamped to `1...maxRecordsPerRequest`) is the only knob. The shared engine is `chunkedBatches` (`CloudKitService+BatchChunking.swift`). diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift index d61f9933..37f6d776 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/AcceptCommand.swift @@ -84,7 +84,7 @@ public struct AcceptCommand: MistDemoCommand, OutputFormatting { let service = try MistKitClientFactory.create(for: config.base) let shortGUIDs = config.shortGUIDs.map { - ShortGUID( + ShortGUIDDictionary( value: $0, shouldFetchRootRecord: config.fetchRootRecord, rootRecordDesiredKeys: config.fields diff --git a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift index b1e49919..c31e4159 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Commands/ResolveCommand.swift @@ -84,7 +84,7 @@ public struct ResolveCommand: MistDemoCommand, OutputFormatting { let service = try MistKitClientFactory.create(for: config.base) let shortGUIDs = config.shortGUIDs.map { - ShortGUID( + ShortGUIDDictionary( value: $0, shouldFetchRootRecord: config.fetchRootRecord, rootRecordDesiredKeys: config.fields diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift index 5b695b97..4431894d 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/AcceptSharesPhase.swift @@ -60,7 +60,7 @@ internal struct AcceptSharesPhase: IntegrationPhase { } let results = try await context.service.acceptShares([ - ShortGUID(value: shortGUID) + ShortGUIDDictionary(value: shortGUID) ]) print( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift index 0c56b379..b62ad0fd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ResolveRecordsPhase.swift @@ -60,7 +60,7 @@ internal struct ResolveRecordsPhase: IntegrationPhase { } let results = try await context.service.resolveShares([ - ShortGUID(value: shortGUID) + ShortGUIDDictionary(value: shortGUID) ]) print( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift index 3161aa8f..b601bd53 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift @@ -116,7 +116,7 @@ internal struct ShareCreateAndAcceptPhase: IntegrationPhase { print(" Share URL: \(created.shareURL.absoluteString)") } - let shortGUID = ShortGUID( + let shortGUID = ShortGUIDDictionary( value: created.shortGUID, shouldFetchRootRecord: true ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift index 29c6d9cb..377b87b7 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend+Shares.swift @@ -42,7 +42,7 @@ extension CloudKitService { ) async throws -> [ShareRecordInfo] { try await resolveShares( shortGUIDs.map { - ShortGUID( + ShortGUIDDictionary( value: $0, shouldFetchRootRecord: fetchRootRecord, rootRecordDesiredKeys: fields @@ -58,7 +58,7 @@ extension CloudKitService { ) async throws -> [ShareRecordInfo] { try await acceptShares( shortGUIDs.map { - ShortGUID( + ShortGUIDDictionary( value: $0, shouldFetchRootRecord: fetchRootRecord, rootRecordDesiredKeys: fields diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift index 19d2eecd..d3ac709a 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ShareOperations.swift @@ -46,7 +46,7 @@ try consumePendingError() return shortGUIDs.map { guid in ShareRecordInfo( - shortGUID: ShortGUID(value: guid), + shortGUID: ShortGUIDDictionary(value: guid), rootRecordName: "stub-root-\(guid)", participantPermission: .readWrite, participantStatus: .accepted @@ -67,7 +67,7 @@ try consumePendingError() return shortGUIDs.map { guid in ShareRecordInfo( - shortGUID: ShortGUID(value: guid), + shortGUID: ShortGUIDDictionary(value: guid), rootRecordName: "stub-root-\(guid)", participantPermission: .readWrite, participantStatus: .accepted diff --git a/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift index bd0020cb..248081a1 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+ShareOperations.swift @@ -36,9 +36,9 @@ extension CloudKitService { /// Given the short GUIDs carried by share URLs, returns the root record, /// the governing `cloudKit.share` record, the owner's identity, and the /// caller's participation in each share. Set - /// ``ShortGUID/shouldFetchRootRecord`` to have CloudKit include the root + /// ``ShortGUIDDictionary/shouldFetchRootRecord`` to have CloudKit include the root /// record itself, optionally narrowed by - /// ``ShortGUID/rootRecordDesiredKeys``. + /// ``ShortGUIDDictionary/rootRecordDesiredKeys``. /// /// Routed against the public database with web-auth credentials — Apple's /// reference fixes the path's database scope to `public`, and resolution is @@ -53,7 +53,7 @@ extension CloudKitService { /// a bad short GUID fails the entire call rather than producing a per-item /// failure. public func resolveShares( - _ shortGUIDs: [ShortGUID] + _ shortGUIDs: [ShortGUIDDictionary] ) async throws(CloudKitError) -> [ShareRecordInfo] { do { let client = try self.client(for: .public(.requires(.webAuth))) @@ -97,7 +97,7 @@ extension CloudKitService { /// a bad or already-accepted short GUID fails the entire call rather than /// producing a per-item failure. public func acceptShares( - _ shortGUIDs: [ShortGUID] + _ shortGUIDs: [ShortGUIDDictionary] ) async throws(CloudKitError) -> [ShareRecordInfo] { do { let client = try self.client(for: .public(.requires(.webAuth))) diff --git a/Sources/MistKit/Models/Sharing/CreatedShare.swift b/Sources/MistKit/Models/Sharing/CreatedShare.swift index dd64971e..6946ff6b 100644 --- a/Sources/MistKit/Models/Sharing/CreatedShare.swift +++ b/Sources/MistKit/Models/Sharing/CreatedShare.swift @@ -39,12 +39,12 @@ public struct CreatedShare: Sendable { // swift-format-ignore: NeverForceUnwrap /// Base URL for iCloud share invite links (`https://www.icloud.com/share`). /// - /// Append a ``ShortGUID/Value`` path component to build a full invite URL. + /// Append a ``ShortGUID`` path component to build a full invite URL. public static let shareURLBase = URL(string: "https://www.icloud.com/share")! // swiftlint:enable force_unwrapping /// The short GUID CloudKit assigned to the share (and shared root). - public let shortGUID: ShortGUID.Value + public let shortGUID: ShortGUID /// The iCloud share invite URL (`https://www.icloud.com/share/{shortGUID}`). public let shareURL: URL /// Share-specific keys lifted from the `cloudkit.share` record. @@ -62,7 +62,7 @@ public struct CreatedShare: Sendable { /// - rootRecord: The root record that was shared. /// - shareRecordName: The `cloudkit.share` record name. public init( - shortGUID: ShortGUID.Value, + shortGUID: ShortGUID, shareURL: URL, share: ShareInfo, rootRecord: RecordInfo, @@ -76,7 +76,7 @@ public struct CreatedShare: Sendable { } /// Build the standard iCloud share invite URL for a short GUID value. - public static func shareURL(forShortGUID shortGUID: ShortGUID.Value) -> URL { + public static func shareURL(forShortGUID shortGUID: ShortGUID) -> URL { shareURLBase.appendingPathComponent(shortGUID) } } diff --git a/Sources/MistKit/Models/Sharing/ShareInfo.swift b/Sources/MistKit/Models/Sharing/ShareInfo.swift index 1ade9a82..f19bce03 100644 --- a/Sources/MistKit/Models/Sharing/ShareInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareInfo.swift @@ -51,7 +51,7 @@ public struct ShareInfo: Codable, Sendable { public static let recordType = "cloudkit.share" /// The short GUID identifying this share. - public let shortGUID: ShortGUID.Value + public let shortGUID: ShortGUID /// The public's read and write permissions on the shared record. public let publicPermission: SharePermission /// The participants in the share. @@ -69,7 +69,7 @@ public struct ShareInfo: Codable, Sendable { /// - owner: The owner of the shared record. /// - currentUserParticipant: The current user's participation. public init( - shortGUID: ShortGUID.Value, + shortGUID: ShortGUID, publicPermission: SharePermission, participants: [ShareParticipant] = [], owner: ShareParticipant, diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift index e1f0a74b..11b30669 100644 --- a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -32,18 +32,18 @@ internal import MistKitOpenAPI /// Information about a shared record, returned by `records/resolve` and /// `records/accept`. /// -/// One `ShareRecordInfo` is produced per requested ``ShortGUID``, in request -/// order. ``shortGUID`` is always present; other fields stay optional because -/// CloudKit populates the result differently depending on the operation and -/// on whether the caller asked for the root record -/// (``ShortGUID/shouldFetchRootRecord``). +/// One `ShareRecordInfo` is produced per requested ``ShortGUIDDictionary``, in +/// request order. ``shortGUID`` is always present; other fields stay optional +/// because CloudKit populates the result differently depending on the +/// operation and on whether the caller asked for the root record +/// (``ShortGUIDDictionary/shouldFetchRootRecord``). /// /// When ``potentialMatchList`` is non-empty CloudKit could not identify the /// caller against a single invited participant; the user must pick which /// invitation they are claiming before the share can be accepted. public struct ShareRecordInfo: Codable, Sendable { /// The short GUID this result was resolved from. - public let shortGUID: ShortGUID + public let shortGUID: ShortGUIDDictionary /// The container holding the shared record. public let containerIdentifier: String? /// The database scope holding the shared record. @@ -54,8 +54,8 @@ public struct ShareRecordInfo: Codable, Sendable { public let zoneID: ZoneID? /// The name of the root record that was shared. public let rootRecordName: String? - /// The shared root record, when ``ShortGUID/shouldFetchRootRecord`` asked - /// for it. + /// The shared root record, when ``ShortGUIDDictionary/shouldFetchRootRecord`` + /// asked for it. public let rootRecord: RecordInfo? /// The `cloudkit.share` record governing the share. public let share: RecordInfo? @@ -81,7 +81,7 @@ public struct ShareRecordInfo: Codable, Sendable { guard let shortGUIDSchema = schema.shortGUID else { try ConversionError.shareResultMissingShortGUID.reportAndThrow() } - self.shortGUID = ShortGUID(from: shortGUIDSchema) + self.shortGUID = ShortGUIDDictionary(from: shortGUIDSchema) self.containerIdentifier = schema.containerIdentifier self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) self.environment = schema.environment.map(Environment.init(from:)) @@ -130,7 +130,7 @@ public struct ShareRecordInfo: Codable, Sendable { /// - webpageURL: The dashboard-configured fallback webpage. /// - potentialMatchList: Candidate participants to disambiguate the caller. public init( - shortGUID: ShortGUID, + shortGUID: ShortGUIDDictionary, containerIdentifier: String? = nil, databaseScope: ShareDatabaseScope? = nil, environment: Environment? = nil, diff --git a/Sources/MistKit/Models/Sharing/ShortGUID.swift b/Sources/MistKit/Models/Sharing/ShortGUIDDictionary.swift similarity index 72% rename from Sources/MistKit/Models/Sharing/ShortGUID.swift rename to Sources/MistKit/Models/Sharing/ShortGUIDDictionary.swift index 6afdb8d4..4d647712 100644 --- a/Sources/MistKit/Models/Sharing/ShortGUID.swift +++ b/Sources/MistKit/Models/Sharing/ShortGUIDDictionary.swift @@ -1,5 +1,5 @@ // -// ShortGUID.swift +// ShortGUIDDictionary.swift // MistKit // // Created by Leo Dion. @@ -29,20 +29,23 @@ internal import MistKitOpenAPI -/// A short global identifier for a shared record. +/// Opaque short-GUID token string as returned by CloudKit. +/// +/// Appears as a bare string on record / share payloads. The resolve and +/// accept request/result dictionary shape is ``ShortGUIDDictionary``. +public typealias ShortGUID = String + +/// CloudKit's ShortGUID Dictionary — a short GUID plus optional root-fetch +/// options used by `records/resolve` and `records/accept`. /// /// CloudKit assigns a short GUID to a record when it is shared — either /// explicitly, by setting `createShortGUID` when creating the record, or -/// implicitly, when a `cloudKit.share` record is created for it. The GUID is -/// what a share URL carries, and it is the handle used to resolve -/// (``CloudKitService/resolveShares(_:)``) and accept -/// (``CloudKitService/acceptShares(_:)``) a share. -public struct ShortGUID: Codable, Sendable, Equatable, Hashable { - /// Opaque short-GUID token string as returned by CloudKit. - public typealias Value = String - +/// implicitly, when a `cloudkit.share` record is created for it. The GUID +/// value is what a share URL carries; this dictionary wraps that value with +/// knobs for resolve/accept. +public struct ShortGUIDDictionary: Codable, Sendable, Equatable, Hashable { /// The value of the short global ID. - public let value: Value + public let value: ShortGUID /// Whether the root record should be fetched alongside the share. /// /// When `nil`, CloudKit applies its own default. @@ -52,14 +55,14 @@ public struct ShortGUID: Codable, Sendable, Equatable, Hashable { /// When `nil`, every field of the root record is returned. public let rootRecordDesiredKeys: [String]? - /// Initialize a short GUID. + /// Initialize a short GUID dictionary. /// - Parameters: /// - value: The value of the short global ID. /// - shouldFetchRootRecord: Whether to fetch the root record alongside /// the share. /// - rootRecordDesiredKeys: Field names limiting the root record payload. public init( - value: Value, + value: ShortGUID, shouldFetchRootRecord: Bool? = nil, rootRecordDesiredKeys: [String]? = nil ) { @@ -77,11 +80,11 @@ public struct ShortGUID: Codable, Sendable, Equatable, Hashable { // MARK: - Internal Conversion extension Components.Schemas.ShortGUID { - internal init(from shortGUID: ShortGUID) { + internal init(from dictionary: ShortGUIDDictionary) { self.init( - value: shortGUID.value, - shouldFetchRootRecord: shortGUID.shouldFetchRootRecord, - rootRecordDesiredKeys: shortGUID.rootRecordDesiredKeys + value: dictionary.value, + shouldFetchRootRecord: dictionary.shouldFetchRootRecord, + rootRecordDesiredKeys: dictionary.rootRecordDesiredKeys ) } } diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift index 5b3dca94..54257cbc 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Accept.swift @@ -52,7 +52,7 @@ extension CloudKitServiceTests.Sharing { ]) let result = try #require( - try await service.acceptShares([ShortGUID(value: "guid-1")]).first + try await service.acceptShares([ShortGUIDDictionary(value: "guid-1")]).first ) #expect(result.participantStatus == .accepted) #expect(result.participantPermission == .readWrite) @@ -79,8 +79,8 @@ extension CloudKitServiceTests.Sharing { ]) let results = try await service.acceptShares([ - ShortGUID(value: "guid-1"), - ShortGUID(value: "guid-2"), + ShortGUIDDictionary(value: "guid-1"), + ShortGUIDDictionary(value: "guid-2"), ]) #expect(results.map(\.shortGUID.value) == ["guid-1", "guid-2"]) } @@ -100,7 +100,7 @@ extension CloudKitServiceTests.Sharing { ]) let error = await #expect(throws: CloudKitError.self) { - _ = try await service.acceptShares([ShortGUID(value: "guid-1")]) + _ = try await service.acceptShares([ShortGUIDDictionary(value: "guid-1")]) } guard case .badRequest(let reason) = error else { Issue.record("Expected .badRequest, got \(String(describing: error))") diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift index 7ce138f5..8daa0b38 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Resolve.swift @@ -51,7 +51,7 @@ extension CloudKitServiceTests.Sharing { ]) ]) - let results = try await service.resolveShares([ShortGUID(value: "guid-1")]) + let results = try await service.resolveShares([ShortGUIDDictionary(value: "guid-1")]) #expect(results.count == 1) let result = try #require(results.first) @@ -88,12 +88,12 @@ extension CloudKitServiceTests.Sharing { ]) let results = try await service.resolveShares([ - ShortGUID( + ShortGUIDDictionary( value: "guid-1", shouldFetchRootRecord: true, rootRecordDesiredKeys: ["title"] ), - ShortGUID(value: "guid-2"), + ShortGUIDDictionary(value: "guid-2"), ]) #expect(results.map(\.shortGUID.value) == ["guid-1", "guid-2"]) @@ -124,7 +124,7 @@ extension CloudKitServiceTests.Sharing { ]) let result = try #require( - try await service.resolveShares([ShortGUID(value: "guid-1")]).first + try await service.resolveShares([ShortGUIDDictionary(value: "guid-1")]).first ) #expect(result.rootRecord == nil) // The root record *name* is still reported. @@ -144,7 +144,7 @@ extension CloudKitServiceTests.Sharing { ]) let result = try #require( - try await service.resolveShares([ShortGUID(value: "guid-amb")]).first + try await service.resolveShares([ShortGUIDDictionary(value: "guid-amb")]).first ) #expect(result.participantStatus == .invited) #expect(result.potentialMatchList.count == 2) @@ -168,7 +168,7 @@ extension CloudKitServiceTests.Sharing { "resolveShortGUIDs": try Helper.shortGUIDResponse(results: []) ]) - let results = try await service.resolveShares([ShortGUID(value: "guid-1")]) + let results = try await service.resolveShares([ShortGUIDDictionary(value: "guid-1")]) #expect(results.isEmpty) } @@ -187,7 +187,7 @@ extension CloudKitServiceTests.Sharing { ]) let error = await #expect(throws: CloudKitError.self) { - _ = try await service.resolveShares([ShortGUID(value: "nope")]) + _ = try await service.resolveShares([ShortGUIDDictionary(value: "nope")]) } guard case .badRequest(let reason) = error else { Issue.record("Expected .badRequest, got \(String(describing: error))") diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift index 45e0456f..b7aeaf12 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+ShareInfo.swift @@ -52,7 +52,7 @@ extension CloudKitServiceTests.Sharing { ]) let result = try #require( - try await service.resolveShares([ShortGUID(value: "guid-1")]).first + try await service.resolveShares([ShortGUIDDictionary(value: "guid-1")]).first ) let shareInfo = try #require(result.shareInfo) #expect(shareInfo.shortGUID == "guid-1") @@ -89,7 +89,7 @@ extension CloudKitServiceTests.Sharing { { _, _, _ in }, operation: { await #expect(throws: CloudKitError.self) { - _ = try await service.resolveShares([ShortGUID(value: "guid-1")]) + _ = try await service.resolveShares([ShortGUIDDictionary(value: "guid-1")]) } } ) diff --git a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift index d72e0386..fda8a9ec 100644 --- a/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift +++ b/Tests/MistKitTests/Models/Sharing/ShareModelTests.swift @@ -41,21 +41,21 @@ internal struct ShareModelTests { acceptanceStatus: .accepted ) - @Test("ShortGUID round-trips through Codable") - internal func shortGUIDRoundTrips() throws { - let original = ShortGUID( + @Test("ShortGUIDDictionary round-trips through Codable") + internal func shortGUIDDictionaryRoundTrips() throws { + let original = ShortGUIDDictionary( value: "guid-1", shouldFetchRootRecord: true, rootRecordDesiredKeys: ["title", "body"] ) let data = try JSONEncoder().encode(original) - let decoded = try JSONDecoder().decode(ShortGUID.self, from: data) + let decoded = try JSONDecoder().decode(ShortGUIDDictionary.self, from: data) #expect(decoded == original) } - @Test("ShortGUID defaults the optional knobs to nil") - internal func shortGUIDDefaults() { - let shortGUID = ShortGUID(value: "guid-1") + @Test("ShortGUIDDictionary defaults the optional knobs to nil") + internal func shortGUIDDictionaryDefaults() { + let shortGUID = ShortGUIDDictionary(value: "guid-1") #expect(shortGUID.value == "guid-1") #expect(shortGUID.shouldFetchRootRecord == nil) #expect(shortGUID.rootRecordDesiredKeys == nil) @@ -119,7 +119,7 @@ internal struct ShareModelTests { @Test("ShareRecordInfo requires shortGUID and defaults the rest") internal func shareRecordInfoDefaults() { - let info = ShareRecordInfo(shortGUID: ShortGUID(value: "guid-1")) + let info = ShareRecordInfo(shortGUID: ShortGUIDDictionary(value: "guid-1")) #expect(info.shortGUID.value == "guid-1") #expect(info.containerIdentifier == nil) #expect(info.databaseScope == nil)