diff --git a/Package.resolved b/Package.resolved index 11857128b..16067b181 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0385aaff7e0d6e30186b9f42ad615ad3dbcb283b4464c0202bebb483e0b4058f", + "originHash" : "9aafa5656d7a6c49905ab42cb52d23fe8b7cefe1a04a14e537053255e511790a", "pins" : [ { "identity" : "collectionconcurrencykit", diff --git a/native/swift/Example/Example.xcodeproj/project.pbxproj b/native/swift/Example/Example.xcodeproj/project.pbxproj index cd5c0f6ba..27cda073e 100644 --- a/native/swift/Example/Example.xcodeproj/project.pbxproj +++ b/native/swift/Example/Example.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 242132C72CE69CE80021D8E8 /* WordPressAPI */; }; 242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D648D2C3602C1007CA96C /* ListViewData.swift */; }; 242D64922C360687007CA96C /* RootListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64912C360687007CA96C /* RootListView.swift */; }; 242D64942C3608C6007CA96C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64932C3608C6007CA96C /* ListView.swift */; }; @@ -18,6 +19,7 @@ 2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF922B621E9B0014A01D /* ListViewModel.swift */; }; 24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */; }; 24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C3352BAA874C00162AD1 /* LoginManager.swift */; }; + 24E77D032CE44DD900F6998C /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 24E77D022CE44DD900F6998C /* WordPressAPI */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -41,6 +43,8 @@ buildActionMask = 2147483647; files = ( 2479BF912B621CCA0014A01D /* WordPressAPI in Frameworks */, + 242132C82CE69CE80021D8E8 /* WordPressAPI in Frameworks */, + 24E77D032CE44DD900F6998C /* WordPressAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -123,6 +127,8 @@ name = Example; packageProductDependencies = ( 2479BF902B621CCA0014A01D /* WordPressAPI */, + 24E77D022CE44DD900F6998C /* WordPressAPI */, + 242132C72CE69CE80021D8E8 /* WordPressAPI */, ); productName = Example; productReference = 2479BF7D2B621CB60014A01D /* Example.app */; @@ -153,7 +159,7 @@ ); mainGroup = 2479BF742B621CB60014A01D; packageReferences = ( - 2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */, + 242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */, ); productRefGroup = 2479BF7E2B621CB60014A01D /* Products */; projectDirPath = ""; @@ -409,17 +415,25 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 2479BF8F2B621CCA0014A01D /* XCLocalSwiftPackageReference "../../.." */ = { + 242132C62CE69CE80021D8E8 /* XCLocalSwiftPackageReference "../../../../wordpress-rs" */ = { isa = XCLocalSwiftPackageReference; - relativePath = ../../..; + relativePath = "../../../../wordpress-rs"; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 242132C72CE69CE80021D8E8 /* WordPressAPI */ = { + isa = XCSwiftPackageProductDependency; + productName = WordPressAPI; + }; 2479BF902B621CCA0014A01D /* WordPressAPI */ = { isa = XCSwiftPackageProductDependency; productName = WordPressAPI; }; + 24E77D022CE44DD900F6998C /* WordPressAPI */ = { + isa = XCSwiftPackageProductDependency; + productName = WordPressAPI; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 2479BF752B621CB60014A01D /* Project object */; diff --git a/native/swift/Example/Example/ExampleApp.swift b/native/swift/Example/Example/ExampleApp.swift index 30272e1cc..7bc51d1ce 100644 --- a/native/swift/Example/Example/ExampleApp.swift +++ b/native/swift/Example/Example/ExampleApp.swift @@ -1,5 +1,9 @@ import SwiftUI import WordPressAPI +import Combine + +private let userListParams = UserListParams(perPage: 5) +private let postListParams = PostListParams(perPage: 5) @main struct ExampleApp: App { @@ -13,9 +17,9 @@ struct ExampleApp: App { .data .map { $0.asListViewData } }), - RootListData(name: "Users", callback: { - try await WordPressAPI.globalInstance.users.paginatedWithEditContext(params: UserListParams(perPage: 100)) - .map { $0.asListViewData } + RootListData(name: "Users", sequence: { + let sequence = try WordPressAPI.globalInstance.users.sequenceWithEditContext(params: userListParams) + return ListViewSequence(underlyingSequence: sequence) }), RootListData(name: "Plugins", callback: { try await WordPressAPI.globalInstance.plugins.listWithEditContext(params: .init()) @@ -27,9 +31,9 @@ struct ExampleApp: App { value.asListViewData } }), - RootListData(name: "Posts", callback: { - try await WordPressAPI.globalInstance.posts.paginatedWithEditContext(params: PostListParams(perPage: 100)) - .map { $0.asListViewData } + RootListData(name: "Posts", sequence: { + let sequence = try WordPressAPI.globalInstance.posts.sequenceWithEditContext(params: postListParams) + return ListViewSequence(underlyingSequence: sequence) }), RootListData(name: "Site Health Tests", callback: { let items: [any ListViewDataConvertable] = [ diff --git a/native/swift/Example/Example/ListViewData.swift b/native/swift/Example/Example/ListViewData.swift index ab5df794c..63112ea40 100644 --- a/native/swift/Example/Example/ListViewData.swift +++ b/native/swift/Example/Example/ListViewData.swift @@ -1,11 +1,16 @@ import Foundation import WordPressAPI +import WordPressAPIInternal -struct ListViewData: Identifiable { +struct ListViewData: Identifiable, Comparable, Hashable { let id: String let title: String let subtitle: String let fields: [String: String] + + static func < (lhs: ListViewData, rhs: ListViewData) -> Bool { + lhs.title < rhs.title + } } protocol ListViewDataConvertable: Identifiable { @@ -151,3 +156,15 @@ extension PostWithEditContext: ListViewDataConvertable { ListViewData(id: self.id, title: self.title.raw, subtitle: self.slug, fields: [:]) } } + +extension [PostWithEditContext] { + func asListViewData() -> [ListViewData] { + self.map { $0.asListViewData } + } +} + +extension [ListViewDataConvertable] { + func asListViewData() -> [ListViewData] { + self.map { $0.asListViewData } + } +} diff --git a/native/swift/Example/Example/ListViewModel.swift b/native/swift/Example/Example/ListViewModel.swift index 1d20dd164..02e3d9db0 100644 --- a/native/swift/Example/Example/ListViewModel.swift +++ b/native/swift/Example/Example/ListViewModel.swift @@ -2,46 +2,83 @@ import Foundation import SwiftUI import WordPressAPI -@Observable class ListViewModel { +@MainActor +protocol ListViewModel { + + /// Guarantee only one object with each ID, but allow updating the object when new data comes in + var listItems: [String: ListViewData] { get } + + var shouldPresentAlert: Bool { get set } + + var error: MyError? { get set } + + func task() async +} + +@Observable class SequenceListViewModel: ListViewModel { + var listItems: [String: ListViewData] = [String: ListViewData](minimumCapacity: 250) + + typealias SequenceProvider = () throws -> ListViewSequence + + private let sequenceProvider: SequenceProvider + + init(sequenceProvider: @escaping SequenceProvider) { + self.sequenceProvider = sequenceProvider + } + + var shouldPresentAlert: Bool = false + + var error: MyError? + + var sequence: ListViewSequence? + + func task() async { + do { + for try await page in try self.sequenceProvider() { + for item in page { + self.listItems[item.id] = item + } + } + } catch { + self.error = .init(underlyingError: error) + self.shouldPresentAlert = true + } + } + + func reset() { + + } +} + +@Observable class TaskListViewModel: ListViewModel { typealias FetchDataTask = () async throws -> [ListViewData] - var listItems: [ListViewData] = [] + var listItems: [String: ListViewData] = [:] private var dataCallback: FetchDataTask - private var dataTask: Task? var isLoading: Bool = false var error: MyError? var shouldPresentAlert = false - let loginManager: LoginManager - - init(loginManager: LoginManager, dataCallback: @escaping FetchDataTask) { - self.loginManager = loginManager + init(dataCallback: @escaping FetchDataTask) { self.dataCallback = dataCallback } - func startFetching() { - self.error = nil + func task() async { + self.isLoading = true self.shouldPresentAlert = false - self.dataTask = Task { @MainActor in - self.isLoading = true - self.shouldPresentAlert = false - - do { - self.listItems = try await dataCallback() - } catch { - self.error = MyError(underlyingError: error) - self.shouldPresentAlert = true + do { + for item in try await dataCallback() { + listItems[item.id] = item } - - self.isLoading = false + } catch { + self.error = MyError(underlyingError: error) + self.shouldPresentAlert = true } - } - func stopFetching() { - self.dataTask?.cancel() + self.isLoading = false } } @@ -60,3 +97,34 @@ struct MyError: LocalizedError { underlyingError.localizedDescription } } + +struct ListViewSequence: AsyncSequence { + typealias Element = [ListViewData] + + private let underlyingSequence: any AsyncSequence + + init(underlyingSequence: any AsyncSequence) { + self.underlyingSequence = underlyingSequence + } + + struct ListViewIterator: AsyncIteratorProtocol { + var underlyingSequence: any AsyncIteratorProtocol + + mutating func next() async throws -> Element? { + guard let nextElement = try await underlyingSequence.next() else { + return nil + } + + guard let listViewData = nextElement as? [any ListViewDataConvertable] else { + debugPrint("Unable to convert data to `ListViewDataConvertable`") + return nil + } + + return listViewData.asListViewData() + } + } + + func makeAsyncIterator() -> ListViewIterator { + ListViewIterator(underlyingSequence: underlyingSequence.makeAsyncIterator()) + } +} diff --git a/native/swift/Example/Example/UI/ListView.swift b/native/swift/Example/Example/UI/ListView.swift index 79353474c..11742b1df 100644 --- a/native/swift/Example/Example/UI/ListView.swift +++ b/native/swift/Example/Example/UI/ListView.swift @@ -6,7 +6,7 @@ struct ListView: View { var viewModel: ListViewModel var body: some View { - List(viewModel.listItems) { item in + List(viewModel.listItems.values.sorted(), id: \.id) { item in VStack(alignment: .leading) { Text(item.title).font(.headline) Text(item.subtitle).font(.footnote) @@ -29,14 +29,15 @@ struct ListView: View { } } ) - .onAppear(perform: viewModel.startFetching) - .onDisappear(perform: viewModel.stopFetching) + .task { + await viewModel.task() + } } } #Preview { - let viewModel = ListViewModel(loginManager: LoginManager(), dataCallback: { + let viewModel = TaskListViewModel(dataCallback: { [ ListViewData(id: "1234", title: "Item 1", subtitle: "Subtitle", fields: [:]) ] diff --git a/native/swift/Example/Example/UI/RootListView.swift b/native/swift/Example/Example/UI/RootListView.swift index 2a7167f24..8e079a831 100644 --- a/native/swift/Example/Example/UI/RootListView.swift +++ b/native/swift/Example/Example/UI/RootListView.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressAPI +import Combine struct RootListView: View { @@ -16,28 +17,50 @@ struct RootListViewItem: View { let item: RootListData var body: some View { - VStack(alignment: .leading, spacing: 4.0) { - NavigationLink { - ListView( - viewModel: ListViewModel( - loginManager: LoginManager(), - dataCallback: self.item.callback + switch item { + case .callback(let name, let fetchDataTask): + VStack(alignment: .leading, spacing: 4.0) { + NavigationLink { + ListView( + viewModel: TaskListViewModel(dataCallback: fetchDataTask) ) - ) - } label: { - Text(item.name) + } label: { + Text(name) + } + } + + case .sequence(let name, let sequenceProvider): + VStack(alignment: .leading, spacing: 4.0) { + NavigationLink { + ListView( + viewModel: SequenceListViewModel(sequenceProvider: sequenceProvider) + ) + } label: { + Text(name) + } } } } } -struct RootListData: Identifiable { +enum RootListData: Identifiable { - let name: String - let callback: ListViewModel.FetchDataTask + case callback(String, TaskListViewModel.FetchDataTask) + case sequence(String, SequenceListViewModel.SequenceProvider) var id: String { - self.name + switch self { + case .callback(let id, _): id + case .sequence(let id, _): id + } + } + + init(name: String, callback: @escaping TaskListViewModel.FetchDataTask) { + self = .callback(name, callback) + } + + init(name: String, sequence: @escaping SequenceListViewModel.SequenceProvider) { + self = .sequence(name, sequence) } } diff --git a/native/swift/Sources/wordpress-api/Pagination.swift b/native/swift/Sources/wordpress-api/Pagination.swift index 92c7e8ea5..a7b6dec5c 100644 --- a/native/swift/Sources/wordpress-api/Pagination.swift +++ b/native/swift/Sources/wordpress-api/Pagination.swift @@ -1,6 +1,6 @@ import Foundation -public protocol PaginatableResponse { +public protocol PaginatableResponse: Sendable { associatedtype ParamsType associatedtype DataType @@ -8,6 +8,8 @@ public protocol PaginatableResponse { var prevPageParams: ParamsType? { get } var data: [DataType] { get } + + init(data: [DataType], headerMap: WpNetworkHeaderMap, nextPageParams: ParamsType?, prevPageParams: ParamsType?) } public protocol PaginationAwareExecutor { @@ -32,6 +34,18 @@ public protocol PaginationAwareExecutor { func paginatedWithEmbedContext( params: EmbedContextResponseType.ParamsType ) async throws -> [EmbedContextResponseType.DataType] + + func sequenceWithEditContext( + params: EditContextResponseType.ParamsType + ) -> PaginationSequence + + func sequenceWithViewContext( + params: ViewContextResponseType.ParamsType + ) -> PaginationSequence + + func sequenceWithEmbedContext( + params: EmbedContextResponseType.ParamsType + ) -> PaginationSequence } extension PaginationAwareExecutor { @@ -106,20 +120,80 @@ extension PaginationAwareExecutor { return allObjects } + + public func sequenceWithEditContext( + params: EditContextResponseType.ParamsType + ) -> PaginationSequence { + PaginationSequence(params: params) { params in + try await self.listWithEditContext(params: params) + } + } + + public func sequenceWithViewContext( + params: ViewContextResponseType.ParamsType + ) -> PaginationSequence { + PaginationSequence(params: params) { params in + try await self.listWithViewContext(params: params) + } + } + + public func sequenceWithEmbedContext( + params: EmbedContextResponseType.ParamsType + ) -> PaginationSequence { + PaginationSequence(params: params) { params in + try await self.listWithEmbedContext(params: params) + } + } +} + +public struct PaginationSequence: AsyncSequence { + public typealias Transformer = (ResponseType.ParamsType) async throws -> ResponseType + + private let params: ResponseType.ParamsType + private let transform: Transformer + + init(params: ResponseType.ParamsType, transform: @escaping Transformer) { + self.params = params + self.transform = transform + } + + public struct AsyncIterator: AsyncIteratorProtocol { + private var nextPageParams: ResponseType.ParamsType? + private let transform: Transformer + + init(params: ResponseType.ParamsType, transform: @escaping Transformer) { + self.nextPageParams = params + self.transform = transform + } + + public mutating func next() async throws -> [ResponseType.DataType]? { + guard let nextPageParams else { + return nil + } + + let response = try await self.transform(nextPageParams) + self.nextPageParams = response.nextPageParams + return response.data + } + } + + public func makeAsyncIterator() -> AsyncIterator { + AsyncIterator(params: params, transform: self.transform) + } } // MARK: - Posts -extension PostsRequestListWithEditContextResponse: PaginatableResponse { +extension PostsRequestListWithEditContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = PostListParams public typealias DataType = PostWithEditContext } -extension PostsRequestListWithViewContextResponse: PaginatableResponse { +extension PostsRequestListWithViewContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = PostListParams public typealias DataType = PostWithViewContext } -extension PostsRequestListWithEmbedContextResponse: PaginatableResponse { +extension PostsRequestListWithEmbedContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = PostListParams public typealias DataType = PostWithEmbedContext } @@ -131,17 +205,17 @@ extension PostsRequestExecutor: PaginationAwareExecutor { } // MARK: - Users -extension UsersRequestListWithEditContextResponse: PaginatableResponse { +extension UsersRequestListWithEditContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = UserListParams public typealias DataType = UserWithEditContext } -extension UsersRequestListWithViewContextResponse: PaginatableResponse { +extension UsersRequestListWithViewContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = UserListParams public typealias DataType = UserWithViewContext } -extension UsersRequestListWithEmbedContextResponse: PaginatableResponse { +extension UsersRequestListWithEmbedContextResponse: PaginatableResponse, @unchecked Sendable { public typealias ParamsType = UserListParams public typealias DataType = UserWithEmbedContext } diff --git a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift index 3449f7390..ab0e38895 100644 --- a/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift +++ b/native/swift/Sources/wordpress-api/SafeRequestExecutor.swift @@ -7,7 +7,7 @@ import WordPressAPIInternal import FoundationNetworking #endif -public protocol SafeRequestExecutor: RequestExecutor { +public protocol SafeRequestExecutor: RequestExecutor, Sendable { func execute(_ request: WpNetworkRequest) async -> Result } diff --git a/native/swift/Tests/wordpress-api/SendableTests.swift b/native/swift/Tests/wordpress-api/SendableTests.swift new file mode 100644 index 000000000..82c41eac1 --- /dev/null +++ b/native/swift/Tests/wordpress-api/SendableTests.swift @@ -0,0 +1,25 @@ +import Testing +import WordPressAPI + +struct SendableTests { + + private static let sendables: [Sendable] = [ + PostsRequestListWithEditContextResponse.empty, + PostsRequestListWithViewContextResponse.empty, + PostsRequestListWithEmbedContextResponse.empty, + + UsersRequestListWithEditContextResponse.empty, + UsersRequestListWithViewContextResponse.empty, + UsersRequestListWithEmbedContextResponse.empty + ] + + /// This might seem like a weird test – why are we checking such a specific implementation detail? + /// + /// This ensures that we don't inadvertently change these types (which we are adding an unsafe `Sendable` + /// conformance to) in Uniffi from `uniffi::Record` to `uniffi::Object`. This removes their ability to + /// be `Sendable`, and our conformance would no longer be safe + @Test("Test that late-conforming sendable types are safe", arguments: sendables) + func testThatTypesAreSendable(_ type: Sendable) { + #expect(Mirror(reflecting: type).displayStyle == .struct) + } +} diff --git a/native/swift/Tests/wordpress-api/Support/Extensions.swift b/native/swift/Tests/wordpress-api/Support/Extensions.swift index a24b603dd..3ae3012c0 100644 --- a/native/swift/Tests/wordpress-api/Support/Extensions.swift +++ b/native/swift/Tests/wordpress-api/Support/Extensions.swift @@ -13,6 +13,12 @@ extension WpNetworkHeaderMap { } } +extension PaginatableResponse { + static var empty: Self { + Self(data: [], headerMap: .empty, nextPageParams: nil, prevPageParams: nil) + } +} + // These `Sendable` conformances are **NOT** safe – they're for the test suite only. // // Until or unless `WpNetworkRequest` and `WpNetworkRequest` become `uniffi::Record` (thus Structs)