Skip to content

Commit

Permalink
Move users to a new WordPressCore module (#24014)
Browse files Browse the repository at this point in the history
* Move users to a new WordPressCore module

* Adopt wordpress-rs API changes (pending SPM update)

* Update wordpress-rs

* Fix unit tests compiling issues

* Update test plan to include the tests in the Modules Swift Package

* Rename WordPressUI's test target as WordPressUIUnitTests

The name WordPressUITests conflicts with the name of WordPress target's
UI Tests target
  • Loading branch information
crazytonyli authored Jan 29, 2025
1 parent 55bc522 commit a0d6a8f
Show file tree
Hide file tree
Showing 37 changed files with 168 additions and 142 deletions.
7 changes: 5 additions & 2 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ let package = Package(
.package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "wpios-edition"),
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
// We can't use wordpress-rs branches nor commits here. Only tags work.
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20241116"),
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250127"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", revision: "fb31301ea6a94376237947afb4242f75c074f43c"),
.package(url: "https://github.com/Automattic/color-studio", branch: "trunk"),
],
Expand All @@ -63,6 +63,7 @@ let package = Package(
.product(name: "XCUITestHelpers", package: "XCUITestHelpers"),
], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressCore", dependencies: [.target(name: "WordPressShared"), .product(name: "WordPressAPI", package: "wordpress-rs")]),
.target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]),
.target(name: "WordPressTesting", resources: [.process("Resources")]),
Expand All @@ -85,7 +86,8 @@ let package = Package(
]),
.testTarget(name: "WordPressSharedTests", dependencies: [.target(name: "WordPressShared")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "WordPressSharedObjCTests", dependencies: [.target(name: "WordPressShared"), .target(name: "WordPressTesting")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "WordPressUITests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "WordPressUIUnitTests", dependencies: [.target(name: "WordPressUI")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "WordPressCoreTests", dependencies: [.target(name: "WordPressCore")]),
]
)

Expand Down Expand Up @@ -156,6 +158,7 @@ enum XcodeSupport {
"WordPressShared",
"AsyncImageKit",
"WordPressUI",
"WordPressCore",
.product(name: "Alamofire", package: "Alamofire"),
.product(name: "AutomatticAbout", package: "AutomatticAbout-swift"),
.product(name: "AutomatticTracks", package: "Automattic-Tracks-iOS"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
/// An abstraction of local data storage, with CRUD operations.
public protocol DataStore: Actor {
associatedtype T: Identifiable & Sendable
associatedtype Query
associatedtype Query: Sendable

func list(query: Query) async throws -> [T]
func delete(query: Query) async throws
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import Combine
@preconcurrency import Combine

/// A `DataStore` type that stores data in memory.
public protocol InMemoryDataStore: DataStore {
Expand All @@ -16,11 +16,11 @@ public protocol InMemoryDataStore: DataStore {

public extension InMemoryDataStore {
func delete(query: Query) async throws {
var updated = Set<T.ID>()
let result = try await list(query: query)
result.forEach {
if storage.removeValue(forKey: $0.id) != nil {
updated.insert($0.id)
var updated = Set<T.ID>()
for item in result {
if storage.removeValue(forKey: item.id) != nil {
updated.insert(item.id)
}
}

Expand All @@ -31,9 +31,9 @@ public extension InMemoryDataStore {

func store(_ data: [T]) async throws {
var updated = Set<T.ID>()
data.forEach {
updated.insert($0.id)
self.storage[$0.id] = $0
for item in data {
updated.insert(item.id)
self.storage[item.id] = item
}

if !updated.isEmpty {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import Foundation
import WordPressShared

public struct DisplayUser: Identifiable, Codable, Hashable {
public let id: Int32
public struct DisplayUser: Identifiable, Codable, Hashable, Sendable {
public let id: Int64
public let handle: String
public let username: String
public let firstName: String
Expand All @@ -17,7 +16,7 @@ public struct DisplayUser: Identifiable, Codable, Hashable {
public let biography: String?

public init(
id: Int32,
id: Int64,
handle: String,
username: String,
firstName: String,
Expand All @@ -42,7 +41,7 @@ public struct DisplayUser: Identifiable, Codable, Hashable {
self.biography = biography
}

static let MockUser = DisplayUser(
public static let mockUser = DisplayUser(
id: 16,
handle: "@person",
username: "example",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import Foundation
import Combine
@preconcurrency import Combine
import WordPressShared

public protocol UserDataStore: DataStore where T == DisplayUser, Query == UserDataStoreQuery {
}

public enum UserDataStoreQuery: Equatable, Sendable {
case all
case id(Set<DisplayUser.ID>)
case search(String)
}

public actor InMemoryUserDataStore: UserDataStore, InMemoryDataStore {
public typealias T = DisplayUser
Expand All @@ -11,6 +21,8 @@ public actor InMemoryUserDataStore: UserDataStore, InMemoryDataStore {
updates.send(completion: .finished)
}

public init() {}

public func list(query: Query) throws -> [T] {
switch query {
case .all:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import Foundation
import Combine
import WordPressAPI
import WordPressUI

/// UserService is responsible for fetching user acounts via the .org REST API – it's the replacement for `UsersService` (the XMLRPC-based approach)
///
actor UserService: UserServiceProtocol, UserDataStoreProvider {
public actor UserService: UserServiceProtocol {
private let client: WordPressClient

private let _dataStore: InMemoryUserDataStore = .init()
var userDataStore: any UserDataStore { _dataStore }
private let userDataStore: InMemoryUserDataStore = .init()

private var _currentUser: UserWithEditContext?
private var currentUser: UserWithEditContext? {
Expand All @@ -21,47 +18,59 @@ actor UserService: UserServiceProtocol, UserDataStoreProvider {
}
}

init(client: WordPressClient) {
public init(client: WordPressClient) {
self.client = client
}

func fetchUsers() async throws {
public func fetchUsers() async throws {
let sequence = await client.api.users.sequenceWithEditContext(params: .init(perPage: 100))
var started = false
for try await users in sequence {
if !started {
try await _dataStore.delete(query: .all)
try await userDataStore.delete(query: .all)
}

try await _dataStore.store(users.compactMap { DisplayUser(user: $0) })
try await userDataStore.store(users.compactMap { DisplayUser(user: $0) })

started = true
}
}

func isCurrentUserCapableOf(_ capability: String) async -> Bool {
public func isCurrentUserCapableOf(_ capability: String) async -> Bool {
await currentUser?.capabilities.keys.contains(capability) == true
}

func deleteUser(id: Int32, reassigningPostsTo newUserId: Int32) async throws {
public func deleteUser(id: Int64, reassigningPostsTo newUserId: Int64) async throws {
let result = try await client.api.users.delete(
userId: id,
params: UserDeleteParams(reassign: newUserId)
).data

// Remove the deleted user from the cached users list.
if result.deleted {
try await _dataStore.delete(query: .id([id]))
try await userDataStore.delete(query: .id([id]))
}
}

func setNewPassword(id: Int32, newPassword: String) async throws {
public func setNewPassword(id: Int64, newPassword: String) async throws {
_ = try await client.api.users.update(
userId: Int32(id),
userId: id,
params: UserUpdateParams(password: newPassword)
)
}

public func allUsers() async throws -> [DisplayUser] {
try await userDataStore.list(query: .all)
}

public func streamSearchResult(input: String) async -> AsyncStream<Result<[DisplayUser], Error>> {
await userDataStore.listStream(query: .search(input))
}

public func streamAll() async -> AsyncStream<Result<[DisplayUser], Error>> {
await userDataStore.listStream(query: .all)
}

}

private extension DisplayUser {
Expand All @@ -86,12 +95,9 @@ private extension DisplayUser {
}

static func profilePhotoUrl(for user: UserWithEditContext) -> URL? {
// The key is the size of the avatar. Get the largetst one, which is 96x96px.
// https://github.com/WordPress/wordpress-develop/blob/6.6.2/src/wp-includes/rest-api.php#L1253-L1260
guard let url = user.avatarUrls?
.max(by: { $0.key.compare($1.key, options: .numeric) == .orderedAscending } )?
.value
else { return nil }
guard let url = user.avatarUrls?[.size96] ?? user.avatarUrls?[.size48] ?? user.avatarUrls?[.size24], let url else {
return nil
}

return URL(string: url)
}
Expand Down
18 changes: 18 additions & 0 deletions Modules/Sources/WordPressCore/Users/UserServiceProtocol.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation
import WordPressAPI

public protocol UserServiceProtocol: Actor {
func fetchUsers() async throws

func isCurrentUserCapableOf(_ capability: String) async -> Bool

func setNewPassword(id: UserId, newPassword: String) async throws

func deleteUser(id: UserId, reassigningPostsTo newUserId: UserId) async throws

func allUsers() async throws -> [DisplayUser]

func streamSearchResult(input: String) async -> AsyncStream<Result<[DisplayUser], Error>>

func streamAll() async -> AsyncStream<Result<[DisplayUser], Error>>
}
14 changes: 14 additions & 0 deletions Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation
import WordPressAPI

public actor WordPressClient {

public let api: WordPressAPI
private let rootUrl: String

public init(api: WordPressAPI, rootUrl: ParsedUrl) {
self.api = api
self.rootUrl = rootUrl.url()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import OHHTTPStubsSwift
private let cache = MockMemoryCache()

init() async throws {
sut = ImageDownloader(cache: cache, authenticator: nil)
sut = ImageDownloader(cache: cache)
}

deinit {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Foundation
import Testing

@testable import WordPress
import WordPressCore

@Suite(.timeLimit(.minutes(1)))
struct InMemoryDataStoreTests {
Expand All @@ -25,7 +24,7 @@ struct InMemoryDataStoreTests {

Task.detached {
try await Task.sleep(for: .milliseconds(50))
try await store.store([.MockUser])
try await store.store([.mockUser])
}

await confirmation("The stream produces an update", expectedCount: 2) { confirmation in
Expand All @@ -38,7 +37,7 @@ struct InMemoryDataStoreTests {
@Test
func testUpdatesAfterDelete() async throws {
let store: InMemoryUserDataStore = InMemoryUserDataStore()
try await store.store([.MockUser])
try await store.store([.mockUser])

let stream = await store.listStream(query: .all)

Expand Down
6 changes: 3 additions & 3 deletions WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "5ebdf4185e258d4ee09d79ca86bc6b3cf3875e4b1f4017498da1dc8c489e66cb",
"originHash" : "1bb18e6f566af95793ebbf960c90096ab410bb43035e5037d42057ec6deb1f00",
"pins" : [
{
"identity" : "alamofire",
Expand Down Expand Up @@ -373,8 +373,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/Automattic/wordpress-rs",
"state" : {
"branch" : "alpha-20241116",
"revision" : "1249ae77fcea2e836b7878a1b1cffdf6c1080256"
"branch" : "alpha-20250127",
"revision" : "1b4eeb2c3209d522805a859c8fff3e7c77e73665"
}
},
{
Expand Down
2 changes: 2 additions & 0 deletions WordPress/Classes/Extensions/WpApiError+Localized.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ extension WpApiError {
case let .WpError(_, errorMessage, _, _):
let format = NSLocalizedString("generic.error.rest-api-error", value: "Your site sent an error response: %@", comment: "Error message format when REST API returns an error response. The first argument is error message.")
return String(format: format, errorMessage)
case .MediaFileNotFound:
return NSLocalizedString("wordpress.api.upload.media.fileNotFound", value: "Can't locate the media file on the device", comment: "Error message when failing to find selected media file on the user's device.")
}
}
}
2 changes: 1 addition & 1 deletion WordPress/Classes/Login/LoginWithUrlView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ private extension SelfHostedSiteAuthenticator.SignInError {

}

extension WordPressLoginClient.Error {
extension WordPressLoginClientError {

var errorMessage: String? {
switch self {
Expand Down
11 changes: 4 additions & 7 deletions WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import WordPressAuthenticator
final actor SelfHostedSiteAuthenticator {

enum SignInError: Error {
case authentication(WordPressLoginClient.Error)
case authentication(WordPressLoginClientError)
case loadingSiteInfoFailure
case savingSiteFailure
}
Expand Down Expand Up @@ -62,7 +62,7 @@ final actor SelfHostedSiteAuthenticator {
}

@MainActor
func authentication(site: String, from anchor: ASPresentationAnchor?) async throws(WordPressLoginClient.Error) -> WpApiApplicationPasswordDetails {
func authentication(site: String, from anchor: ASPresentationAnchor?) async throws(WordPressLoginClientError) -> WpApiApplicationPasswordDetails {
let appId: WpUuid
let appName: String

Expand All @@ -78,14 +78,11 @@ final actor SelfHostedSiteAuthenticator {
let timestamp = ISO8601DateFormatter.string(from: .now, timeZone: .current, formatOptions: .withInternetDateTime)
let appNameValue = "\(appName) - \(deviceName) (\(timestamp))"

let result = await internalClient.login(
return try await internalClient.login(
site: site,
appName: appNameValue,
appId: appId,
contextProvider: WebAuthenticationPresentationAnchorProvider(anchor: anchor ?? ASPresentationAnchor())
appId: appId
)

return try result.get()
}

private func handleSuccess(_ success: WpApiApplicationPasswordDetails) async throws(SignInError) -> WordPressOrgCredentials {
Expand Down
Loading

0 comments on commit a0d6a8f

Please sign in to comment.