Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ let package = Package(
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.12.1"),
// We can't use wordpress-rs branches nor commits here. Only tags work.
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260114"),
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260122v2"),
.package(
url: "https://github.com/Automattic/color-studio",
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"
Expand Down
103 changes: 101 additions & 2 deletions Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,112 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal

/// Protocol defining the WordPress API methods that WordPressClient needs.
/// This abstraction allows for mocking in tests using the `NoHandle` constructors
/// available on the executor classes.
public protocol WordPressClientAPI: Sendable {
var apiRoot: ApiRootRequestExecutor { get }
var users: UsersRequestExecutor { get }
var themes: ThemesRequestExecutor { get }
var plugins: PluginsRequestExecutor { get }
var comments: CommentsRequestExecutor { get }
var media: MediaRequestExecutor { get }
var taxonomies: TaxonomiesRequestExecutor { get }
var terms: TermsRequestExecutor { get }
var applicationPasswords: ApplicationPasswordsRequestExecutor { get }

func uploadMedia(
params: MediaCreateParams,
fulfilling progress: Progress
) async throws -> MediaRequestCreateResponse
}

/// WordPressAPI already has these properties with the correct types,
/// so conformance is automatic.
extension WordPressAPI: WordPressClientAPI {}

public actor WordPressClient {

public let api: WordPressAPI
public enum Feature {
/// A block theme is required to style the editor.
case blockTheme

/// The block editor settings API is required to style the editor.
case blockEditorSettings

/// Application Password Extras grants additional capabilities using Application Passwords.
case applicationPasswordExtras

/// WordPress.com sites don't all support plugins.
case plugins

public var stringValue: String {
switch self {
case .blockTheme: "is-block-theme"
case .blockEditorSettings: "block-editor-settings"
case .applicationPasswordExtras: "application-password-extras"
case .plugins: "plugins"
}
}
}

public let api: any WordPressClientAPI
public let rootUrl: String

public init(api: WordPressAPI, rootUrl: ParsedUrl) {
private var loadSiteInfoTask: Task<(WpApiDetails, UserWithEditContext, ThemeWithEditContext?), Error>

public init(api: any WordPressClientAPI, rootUrl: ParsedUrl) {
self.api = api
self.rootUrl = rootUrl.url()

self.loadSiteInfoTask = Task { [api] in
async let apiRootTask = try await api.apiRoot.get().data
async let currentUserTask = try await api.users.retrieveMeWithEditContext().data
async let activeThemeTask = try await api.themes.listWithEditContext(
params: ThemeListParams(status: .active)
).data.first(where: { $0.status == .active })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If one of the requests fails, the whole loadSiteInfoTask fails, which means we won't get any of the "site info". That's probably not something we'd want here?


return try await (apiRootTask, currentUserTask, activeThemeTask)
}
}

public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool {
let apiRoot = try await fetchApiRoot()
let isBlockTheme: Bool = try await fetchActiveTheme()?.isBlockTheme ?? false

if let siteId {
return switch feature {
case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings")
case .blockTheme: isBlockTheme
case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins")
case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax")
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: using the urlResolver property in WordPressAPI to find the URL should work on both self-hosted and WP.com sites, so we can merge the two similar branches into one.

}

return switch feature {
case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/settings")
case .blockTheme: isBlockTheme
case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins")
case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax")
}
}

/// Asynchronously read the site's API details. This value is cached internally.
///
private var apiRoot: WpApiDetails {
get async throws {
try await self.fetchApiRoot()
}
}

private func fetchApiRoot() async throws -> WpApiDetails {
// Wait for the `loadSiteInfoTask` to finish the initial load then use that value
return try await loadSiteInfoTask.value.0
}

private func fetchActiveTheme() async throws -> ThemeWithEditContext? {
// Wait for the `loadSiteInfoTask` to finish the initial load then use that value
return try await loadSiteInfoTask.value.2
}
}
180 changes: 180 additions & 0 deletions Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
@testable import WordPressCore

/// Tracks call counts for API methods to verify caching behavior.
final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable {
private let lock = NSLock()

private var _apiRootCallCount = 0
private var _usersCallCount = 0
private var _themesCallCount = 0

var apiRootCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _apiRootCallCount
}

var usersCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _usersCallCount
}

var themesCallCount: Int {
lock.lock()
defer { lock.unlock() }
return _themesCallCount
}

var mockRoutes: Set<String> = []
var mockIsBlockTheme: Bool = false

var apiRoot: ApiRootRequestExecutor {
lock.lock()
_apiRootCallCount += 1
lock.unlock()
return MockApiRootRequestExecutor(routes: mockRoutes)
}

var users: UsersRequestExecutor {
lock.lock()
_usersCallCount += 1
lock.unlock()
return MockUsersRequestExecutor(noHandle: UsersRequestExecutor.NoHandle())
}

var themes: ThemesRequestExecutor {
lock.lock()
_themesCallCount += 1
lock.unlock()
return MockThemesRequestExecutor(isBlockTheme: mockIsBlockTheme)
}

// Unused in WordPressClient.supports() - provide minimal implementations
var plugins: PluginsRequestExecutor { fatalError("Not implemented") }
var comments: CommentsRequestExecutor { fatalError("Not implemented") }
var media: MediaRequestExecutor { fatalError("Not implemented") }
var taxonomies: TaxonomiesRequestExecutor { fatalError("Not implemented") }
var terms: TermsRequestExecutor { fatalError("Not implemented") }
var applicationPasswords: ApplicationPasswordsRequestExecutor { fatalError("Not implemented") }

func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse {
fatalError("Not implemented")
}
}

// MARK: - Mock Executors

final class MockApiRootRequestExecutor: ApiRootRequestExecutor {
private var routes: Set<String>

init(routes: Set<String>) {
self.routes = routes
super.init(noHandle: ApiRootRequestExecutor.NoHandle())
}

required init(unsafeFromHandle handle: UInt64) {
self.routes = []
super.init(unsafeFromHandle: handle)
}

override func getCancellation(context: RequestContext?) async throws -> ApiRootRequestGetResponse {
let mockApiDetails = MockWpApiDetails(routes: routes)
let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle())
return ApiRootRequestGetResponse(data: mockApiDetails, headerMap: mockHeaderMap)
}
}

final class MockUsersRequestExecutor: UsersRequestExecutor {
override init(noHandle: UsersRequestExecutor.NoHandle) {
super.init(noHandle: noHandle)
}

required init(unsafeFromHandle handle: UInt64) {
super.init(unsafeFromHandle: handle)
}

override func retrieveMeWithEditContextCancellation(context: RequestContext?) async throws -> UsersRequestRetrieveMeWithEditContextResponse {
let mockUser = UserWithEditContext(
id: UserId(1),
username: "testuser",
name: "Test User",
firstName: "Test",
lastName: "User",
email: "test@example.com",
url: "",
description: "",
link: "https://example.com/author/testuser",
locale: "en_US",
nickname: "testuser",
slug: "testuser",
registeredDate: "2024-01-01T00:00:00",
roles: [],
capabilities: [:],
extraCapabilities: [:],
avatarUrls: nil
)
let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle())
return UsersRequestRetrieveMeWithEditContextResponse(data: mockUser, headerMap: mockHeaderMap)
}
}

final class MockThemesRequestExecutor: ThemesRequestExecutor {
private var isBlockTheme: Bool

init(isBlockTheme: Bool) {
self.isBlockTheme = isBlockTheme
super.init(noHandle: ThemesRequestExecutor.NoHandle())
}

required init(unsafeFromHandle handle: UInt64) {
self.isBlockTheme = false
super.init(unsafeFromHandle: handle)
}

override func listWithEditContextCancellation(params: ThemeListParams, context: RequestContext?) async throws -> ThemesRequestListWithEditContextResponse {
let mockTheme = ThemeWithEditContext(
stylesheet: ThemeStylesheet(value: "twentytwentyfour"),
template: "twentytwentyfour",
requiresPhp: "7.0",
requiresWp: "6.4",
textdomain: "twentytwentyfour",
version: "1.0",
screenshot: "",
author: ThemeAuthor(raw: "WordPress", rendered: "WordPress"),
authorUri: ThemeAuthorUri(raw: "", rendered: ""),
description: ThemeDescription(raw: "", rendered: ""),
name: ThemeName(raw: "Twenty Twenty-Four", rendered: "Twenty Twenty-Four"),
tags: ThemeTags(raw: [], rendered: ""),
themeUri: ThemeUri(raw: "", rendered: ""),
status: .active,
isBlockTheme: isBlockTheme,
stylesheetUri: "",
templateUri: "",
themeSupports: nil
)
let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle())
return ThemesRequestListWithEditContextResponse(data: [mockTheme], headerMap: mockHeaderMap)
}
}

final class MockWpApiDetails: WpApiDetails {
private var routes: Set<String>

init(routes: Set<String>) {
self.routes = routes
super.init(noHandle: WpApiDetails.NoHandle())
}

required init(unsafeFromHandle handle: UInt64) {
self.routes = []
super.init(unsafeFromHandle: handle)
}

override func hasRoute(route: String) -> Bool {
routes.contains(route)
}
}
Loading