Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
72 changes: 72 additions & 0 deletions Modules/Sources/WordPressCore/ApiCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache

extension WordPressApiCache {
static func bootstrap() -> WordPressApiCache? {
let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache()
instance?.startListeningForUpdates()
return instance
}

private static func onDiskCache() -> WordPressApiCache? {
let cacheURL: URL
do {
cacheURL = try FileManager.default
.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appending(path: "app.sqlite")
} catch {
NSLog("Failed to create api cache file: \(error)")
return nil
}

if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}

if FileManager.default.fileExists(at: cacheURL) {
do {
try FileManager.default.removeItem(at: cacheURL)

if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}
} catch {
NSLog("Failed to delete sqlite database: \(error)")
}
}

return nil
}

private static func onDiskCache(file: URL) -> WordPressApiCache? {
let cache: WordPressApiCache
do {
cache = try WordPressApiCache(url: file)
} catch {
NSLog("Failed to create an instance: \(error)")
return nil
}

do {
_ = try cache.performMigrations()
} catch {
NSLog("Failed to migrate database: \(error)")
return nil
}

return cache
}
Comment on lines +7 to +60
Copy link
Contributor

Choose a reason for hiding this comment

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

The on-disk handling could probably be a bit simpler?

Suggested change
static func bootstrap() -> WordPressApiCache? {
let instance: WordPressApiCache? = .onDiskCache() ?? .memoryCache()
instance?.startListeningForUpdates()
return instance
}
private static func onDiskCache() -> WordPressApiCache? {
let cacheURL: URL
do {
cacheURL = try FileManager.default
.url(for: .libraryDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
.appending(path: "app.sqlite")
} catch {
NSLog("Failed to create api cache file: \(error)")
return nil
}
if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}
if FileManager.default.fileExists(at: cacheURL) {
do {
try FileManager.default.removeItem(at: cacheURL)
if let cache = WordPressApiCache.onDiskCache(file: cacheURL) {
return cache
}
} catch {
NSLog("Failed to delete sqlite database: \(error)")
}
}
return nil
}
private static func onDiskCache(file: URL) -> WordPressApiCache? {
let cache: WordPressApiCache
do {
cache = try WordPressApiCache(url: file)
} catch {
NSLog("Failed to create an instance: \(error)")
return nil
}
do {
_ = try cache.performMigrations()
} catch {
NSLog("Failed to migrate database: \(error)")
return nil
}
return cache
}
static func bootstrap() -> WordPressApiCache? {
let instance: WordPressApiCache? = try? .onDiskCache() ?? .memoryCache()
instance?.startListeningForUpdates()
return instance
}
private static func onDiskCache(url: URL? = nil) throws -> WordPressApiCache? {
let cacheURL: URL = url ?? URL.libraryDirectory.appendingPathComponent("app.sqlite")
if FileManager.default.fileExists(at: cacheURL) {
// If we're not able to initialize the cache, something is wrong with it so we'll delete it entirely
if let cache = try? WordPressApiCache(url: cacheURL) {
return cache
} else {
try FileManager.default.removeItem(at: cacheURL)
}
}
return try WordPressApiCache(url: cacheURL)
}


private static func memoryCache() -> WordPressApiCache? {
do {
let cache = try WordPressApiCache()
_ = try cache.performMigrations()
return cache
} catch {
NSLog("Failed to create memory cache: \(error)")
return nil
}
}
}
4 changes: 2 additions & 2 deletions Modules/Sources/WordPressCore/Plugins/PluginService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,9 @@ private extension PluginService {

func checkPluginUpdates(plugins: [PluginWithViewContext]) async throws {
let updateCheck = try await wpOrgClient.checkPluginUpdates(
// Use a fairely recent version if the actual version is unknown.
// Use a fairly recent version if the actual version is unknown.
wordpressCoreVersion: wordpressCoreVersion ?? "6.6",
siteUrl: ParsedUrl.parse(input: client.rootUrl),
siteUrl: ParsedUrl.parse(input: client.siteURL.absoluteString),
plugins: plugins
)
let updateAvailable = updateCheck.plugins
Expand Down
37 changes: 32 additions & 5 deletions Modules/Sources/WordPressCore/WordPressClient.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache

/// Protocol defining the WordPress API methods that WordPressClient needs.
/// This abstraction allows for mocking in tests using the `NoHandle` constructors
Expand All @@ -15,6 +16,9 @@ public protocol WordPressClientAPI: Sendable {
var taxonomies: TaxonomiesRequestExecutor { get }
var terms: TermsRequestExecutor { get }
var applicationPasswords: ApplicationPasswordsRequestExecutor { get }
var posts: PostsRequestExecutor { get }

func createSelfHostedService(cache: WordPressApiCache) throws -> WpSelfHostedService

func uploadMedia(
params: MediaCreateParams,
Expand Down Expand Up @@ -70,11 +74,34 @@ public actor WordPressClient {
case noActiveTheme
}

public let siteURL: URL

/// The underlying API executor used for making network requests.
public let api: any WordPressClientAPI

/// The root URL of the WordPress site this client is connected to.
public let rootUrl: String
private var _cache: WordPressApiCache?
public var cache: WordPressApiCache? {
get {
if _cache == nil {
_cache = WordPressApiCache.bootstrap()
}
return _cache
}
}

private var _service: WpSelfHostedService?
public var service: WpSelfHostedService? {
get {
if _service == nil, let cache {
do {
_service = try api.createSelfHostedService(cache: cache)
} catch {
NSLog("Failed to create service: \(error)")
Copy link
Contributor

Choose a reason for hiding this comment

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

Could we at least log this to Sentry? I don't love dropping this error.

}
}
return _service
}
}

/// The cached task for fetching site API details.
private var loadSiteInfoTask: Task<WpApiDetails, Error>
Expand All @@ -93,10 +120,10 @@ public actor WordPressClient {
///
/// - Parameters:
/// - api: The API executor to use for network requests.
/// - rootUrl: The parsed root URL of the WordPress site.
public init(api: any WordPressClientAPI, rootUrl: ParsedUrl) {
/// - siteURL: The parsed root URL of the WordPress site.
public init(api: WordPressClientAPI, siteURL: URL) {
self.api = api
self.rootUrl = rootUrl.url()
self.siteURL = siteURL

// These tasks need to be manually restated here because we can't use the task constructors
self.loadSiteInfoTask = Task { try await api.apiRoot.get().data }
Expand Down
6 changes: 6 additions & 0 deletions Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressApiCache
@testable import WordPressCore

/// Tracks call counts for API methods to verify caching behavior.
Expand Down Expand Up @@ -60,6 +61,11 @@ final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable {
var taxonomies: TaxonomiesRequestExecutor { fatalError("Not implemented") }
var terms: TermsRequestExecutor { fatalError("Not implemented") }
var applicationPasswords: ApplicationPasswordsRequestExecutor { fatalError("Not implemented") }
var posts: PostsRequestExecutor { fatalError("Not implemented") }

func createSelfHostedService(cache: WordPressApiCache) throws -> WpSelfHostedService {
fatalError("Not implemented")
}

func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse {
fatalError("Not implemented")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct WordPressClientCachingTests {
mockAPI.mockRoutes = ["/wp-block-editor/v1/settings"]
mockAPI.mockIsBlockTheme = true

let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com"))
let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!)

// First call - should trigger API fetches
let result1 = try await client.supports(.blockEditorSettings)
Expand Down Expand Up @@ -61,7 +61,7 @@ struct WordPressClientCachingTests {
let mockAPI = MockWordPressClientAPI()
mockAPI.mockRoutes = ["/wp-block-editor/v1/sites/12345/settings"]

let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com"))
let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!)

// Call with siteId
let result = try await client.supports(.blockEditorSettings, forSiteId: 12345)
Expand All @@ -81,7 +81,7 @@ struct WordPressClientCachingTests {
mockAPI.mockRoutes = ["/wp-block-editor/v1/settings", "/wp/v2/plugins"]
mockAPI.mockIsBlockTheme = true

let client = try WordPressClient(api: mockAPI, rootUrl: .parse(input: "https://example.com"))
let client = try WordPressClient(api: mockAPI, siteURL: URL(string: "https://example.com")!)

// Make multiple concurrent calls
async let result1 = client.supports(.blockEditorSettings)
Expand Down
7 changes: 7 additions & 0 deletions Sources/WordPressData/Swift/Blog+Plans.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,11 @@
1031] // 2y Ecommerce Plan
.contains(planID?.intValue)
}

public var supportsCoreRESTAPI: Bool {
if isHostedAtWPcom {
return isAtomic()
}
return true
}
}
37 changes: 24 additions & 13 deletions Sources/WordPressData/Swift/Blog+SelfHosted.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,57 +189,68 @@ public extension WpApiApplicationPasswordDetails {
}
}

public enum WordPressSite {
case dotCom(siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, apiRootURL: ParsedUrl, username: String, authToken: String)
public enum WordPressSite: Hashable {
case dotCom(siteURL: URL, siteId: Int, authToken: String)
case selfHosted(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, apiRootURL: ParsedUrl, username: String, authToken: String)

public init(blog: Blog) throws {
let siteURL = try blog.getUrl()
// Directly access the site content when available.
if let restApiRootURL = blog.restApiRootURL,
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
let username = blog.username,
let authToken = try? blog.getApplicationToken() {
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: restApiRootURL, username: username, authToken: authToken)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: siteURL, apiRootURL: restApiRootURL, username: username, authToken: authToken)
} else if let account = blog.account, let siteId = blog.dotComID?.intValue {
// When the site is added via a WP.com account, access the site via WP.com
let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username)
self = .dotCom(siteId: siteId, authToken: authToken)
self = .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken)
} else {
// In theory, this branch should never run, because the two if statements above should have covered all paths.
// But we'll keep it here as the fallback.
let url = try blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString
let apiRootURL = try ParsedUrl.parse(input: url)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
let url = try blog.getUrl()
let apiRootURL = try ParsedUrl.parse(input: blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString)
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: url, apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
}
}

public var siteURL: URL {
switch self {
case let .dotCom(siteURL, _, _):
return siteURL
case let .selfHosted(_, siteURL, _, _, _):
return siteURL
}
}

public static func throughDotCom(blog: Blog) -> Self? {
guard
let siteURL = try? blog.getUrl(),
let account = blog.account,
let siteId = blog.dotComID?.intValue,
let authToken = try? account.authToken ?? WPAccount.token(forUsername: account.username)
else { return nil }

return .dotCom(siteId: siteId, authToken: authToken)
return .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken)
}

public func blog(in context: NSManagedObjectContext) throws -> Blog? {
switch self {
case let .dotCom(siteId, _):
case let .dotCom(_, siteId, _):
return try Blog.lookup(withID: siteId, in: context)
case let .selfHosted(blogId, _, _, _):
case let .selfHosted(blogId, _, _, _, _):
return try context.existingObject(with: blogId)
}
}

public func blogId(in coreDataStack: CoreDataStack) -> TaggedManagedObjectID<Blog>? {
switch self {
case let .dotCom(siteId, _):
case let .dotCom(_, siteId, _):
return coreDataStack.performQuery { context in
guard let blog = try? Blog.lookup(withID: siteId, in: context) else { return nil }
return TaggedManagedObjectID(blog)
}
case let .selfHosted(id, _, _, _):
case let .selfHosted(id, _, _, _, _):
return id
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ class UserListViewModelTests: XCTestCase {
try await super.setUp()

let api = try WordPressAPI(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none)
let client = try WordPressClient(api: api, rootUrl: .parse(input: "https://example.com"))
let client = try WordPressClient(
api: api,
siteURL: URL(string: "https://example.com")!
)
service = UserService(client: client)
viewModel = await UserListViewModel(userService: service, currentUserId: 0)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
} else if showLoading {
ProgressView()
} else if let site {
builder(WordPressClient(site: site))
builder(WordPressClientFactory.shared.instance(for: site))
} else {
RestApiUpgradePrompt(localizedFeatureName: localizedFeatureName) {
Task {
Expand Down
Loading