Skip to content

Commit c4d1470

Browse files
committed
Refactor WordPressSite to be a struct
1 parent a38308d commit c4d1470

16 files changed

Lines changed: 215 additions & 112 deletions

File tree

Modules/Package.resolved

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ let package = Package(
5757
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
5858
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.14.0"),
5959
// We can't use wordpress-rs branches nor commits here. Only tags work.
60-
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20260313"),
60+
.package(url: "https://github.com/automattic/wordpress-rs", branch: "pr-build/1239"),
6161
.package(
6262
url: "https://github.com/Automattic/color-studio",
6363
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"

Modules/Sources/WordPressCore/WordPressClient.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public protocol WordPressClientAPI: Sendable {
2020
var postTypes: PostTypesRequestExecutor { get }
2121
var siteSettings: SiteSettingsRequestExecutor { get }
2222

23-
func createSelfHostedService(cache: WordPressApiCache) throws -> WpService
23+
func createService(cache: WordPressApiCache) throws -> WpService
2424

2525
func uploadMedia(
2626
params: MediaCreateParams,
@@ -99,7 +99,7 @@ public actor WordPressClient {
9999
if let _service {
100100
return _service
101101
}
102-
let service = try api.createSelfHostedService(cache: cache)
102+
let service = try api.createService(cache: cache)
103103
_service = service
104104
return service
105105
}

Modules/Tests/WordPressCoreTests/MockWordPressClientAPI.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable {
7878
var posts: PostsRequestExecutor { fatalError("Not implemented") }
7979
var postTypes: PostTypesRequestExecutor { fatalError("Not implemented") }
8080

81-
func createSelfHostedService(cache: WordPressApiCache) throws -> WpService {
81+
func createService(cache: WordPressApiCache) throws -> WpService {
8282
fatalError("Not implemented")
8383
}
8484

Sources/WordPressData/Swift/Blog+Features.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ extension Blog {
107107
// alt is not supported via XML-RPC API
108108
// https://core.trac.wordpress.org/ticket/58582
109109
// https://github.com/wordpress-mobile/WordPress-Android/issues/18514#issuecomment-1589752274
110-
return supportsRestAPI || supportsCoreRestApi
110+
return supportsRestAPI || hasDirectCoreRESTAPIAccess
111111
case .contactInfo:
112112
return hasRequiredJetpackVersion("8.5") || isHostedAtWPcom
113113
case .blockEditorSettings:

Sources/WordPressData/Swift/Blog+SelfHosted.swift

Lines changed: 135 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,11 @@ public extension Blog {
174174
self.account == nil
175175
}
176176

177-
@objc var supportsCoreRestApi: Bool {
178-
if case .selfHosted = try? WordPressSite(blog: self) {
179-
return true
177+
@objc var hasDirectCoreRESTAPIAccess: Bool {
178+
guard let site = try? WordPressSite(blog: self) else {
179+
return false
180180
}
181-
return false
181+
return site.applicationPasswordCredentials != nil
182182
}
183183
}
184184

@@ -190,58 +190,150 @@ public extension WpApiApplicationPasswordDetails {
190190
}
191191
}
192192

193-
public enum WordPressSite: Hashable {
194-
case dotCom(siteURL: URL, siteId: Int, authToken: String)
195-
case selfHosted(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, apiRootURL: ParsedUrl, username: String, authToken: String)
193+
/// Describes a WordPress site's hosting type, authentication credentials,
194+
/// and API capabilities.
195+
///
196+
/// This is a value type constructed from a `Blog` Core Data object. It captures
197+
/// a snapshot of the site's characteristics at construction time.
198+
///
199+
/// `WordPressSite` is not a one-to-one mapping with `Blog`. It represents the
200+
/// subset of `Blog` instances that have access to the WordPress core REST API
201+
/// (wp/v2). A self-hosted site that only has XML-RPC credentials is not
202+
/// representable as a `WordPressSite`.
203+
///
204+
/// - All WordPress.com sites qualify (wp/v2 is accessed via WP.com REST API
205+
/// with OAuth).
206+
/// - Self-hosted sites must have application password credentials.
207+
public struct WordPressSite {
208+
public let blogId: TaggedManagedObjectID<Blog>
209+
public let siteURL: URL
210+
public let flavor: ApiFlavor
211+
212+
public init(blogId: TaggedManagedObjectID<Blog>, siteURL: URL, flavor: ApiFlavor) {
213+
self.blogId = blogId
214+
self.siteURL = siteURL
215+
self.flavor = flavor
216+
}
217+
}
218+
219+
extension WordPressSite {
220+
public enum ApiFlavor {
221+
/// A site hosted on WordPress.com. Always has OAuth access via
222+
/// WPAccount. May also have application password credentials
223+
/// (e.g., Atomic sites).
224+
case dotCom(DotComCredentials)
225+
226+
/// A self-hosted WordPress site with application password credentials.
227+
/// Application password is required for wp/v2 API access.
228+
case selfHosted(ApplicationPasswordCredentials)
229+
}
230+
}
231+
232+
extension WordPressSite {
233+
public struct DotComCredentials: Hashable {
234+
public let siteId: Int
235+
public let oAuthToken: String
236+
/// Non-nil for Atomic sites that also have application password access.
237+
public let applicationPassword: ApplicationPasswordCredentials?
238+
239+
public init(siteId: Int, oAuthToken: String, applicationPassword: ApplicationPasswordCredentials?) {
240+
self.siteId = siteId
241+
self.oAuthToken = oAuthToken
242+
self.applicationPassword = applicationPassword
243+
}
244+
}
245+
246+
public struct ApplicationPasswordCredentials: Hashable {
247+
public let apiRootURL: ParsedUrl
248+
public let username: String
249+
public let token: String
250+
251+
public init(apiRootURL: ParsedUrl, username: String, token: String) {
252+
self.apiRootURL = apiRootURL
253+
self.username = username
254+
self.token = token
255+
}
256+
}
257+
}
196258

259+
extension WordPressSite: Hashable {
260+
public static func == (lhs: WordPressSite, rhs: WordPressSite) -> Bool {
261+
lhs.blogId == rhs.blogId
262+
}
263+
264+
public func hash(into hasher: inout Hasher) {
265+
hasher.combine(blogId)
266+
}
267+
}
268+
269+
extension WordPressSite {
270+
/// Constructs a `WordPressSite` from a `Blog` Core Data object.
271+
///
272+
/// Throws if the blog lacks enough data to determine its hosting type
273+
/// and at least one valid authentication method.
274+
///
275+
/// For self-hosted sites, application password credentials are required.
276+
/// Sites without them cannot be represented as a `WordPressSite`.
197277
public init(blog: Blog) throws {
198278
let siteURL = try blog.getUrl()
199-
// Directly access the site content when available.
279+
self.blogId = TaggedManagedObjectID(blog)
280+
self.siteURL = siteURL
281+
282+
// Build application password credentials if available.
283+
// These are shared across both hosting types — WordPress.com Atomic
284+
// sites can have them too.
285+
let applicationPassword: ApplicationPasswordCredentials?
200286
if let restApiRootURL = blog.restApiRootURL,
201-
let restApiRootURL = try? ParsedUrl.parse(input: restApiRootURL),
287+
let parsedApiRoot = try? ParsedUrl.parse(input: restApiRootURL),
202288
let username = blog.username,
203-
let authToken = try? blog.getApplicationToken() {
204-
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: siteURL, apiRootURL: restApiRootURL, username: username, authToken: authToken)
205-
} else if let account = blog.account, let siteId = blog.dotComID?.intValue {
206-
// When the site is added via a WP.com account, access the site via WP.com
207-
let authToken = try account.authToken ?? WPAccount.token(forUsername: account.username)
208-
self = .dotCom(siteURL: siteURL, siteId: siteId, authToken: authToken)
289+
let token = try? blog.getApplicationToken() {
290+
applicationPassword = ApplicationPasswordCredentials(
291+
apiRootURL: parsedApiRoot,
292+
username: username,
293+
token: token
294+
)
209295
} else {
210-
// In theory, this branch should never run, because the two if statements above should have covered all paths.
211-
// But we'll keep it here as the fallback.
212-
let url = try blog.getUrl()
213-
let apiRootURL = try ParsedUrl.parse(input: blog.restApiRootURL ?? blog.getUrl().appending(path: "wp-json").absoluteString)
214-
self = .selfHosted(blogId: TaggedManagedObjectID(blog), siteURL: url, apiRootURL: apiRootURL, username: try blog.getUsername(), authToken: try blog.getApplicationToken())
296+
applicationPassword = nil
215297
}
216-
}
217298

218-
public var siteURL: URL {
219-
switch self {
220-
case let .dotCom(siteURL, _, _):
221-
return siteURL
222-
case let .selfHosted(_, siteURL, _, _, _):
223-
return siteURL
299+
// Check for WordPress.com account first. This means Atomic sites
300+
// (which have both an account and application password credentials)
301+
// resolve to `.dotCom`.
302+
if let account = blog.account,
303+
let siteId = blog.dotComID?.intValue {
304+
let authToken = try account.authToken
305+
?? WPAccount.token(forUsername: account.username)
306+
self.flavor = .dotCom(DotComCredentials(
307+
siteId: siteId,
308+
oAuthToken: authToken,
309+
applicationPassword: applicationPassword
310+
))
311+
} else {
312+
// Self-hosted sites must have application password credentials
313+
// for wp/v2 API access.
314+
guard let applicationPassword else {
315+
throw Blog.BlogCredentialsError.blogPasswordMissing
316+
}
317+
self.flavor = .selfHosted(applicationPassword)
224318
}
225319
}
320+
}
226321

227-
public func blog(in context: NSManagedObjectContext) throws -> Blog? {
228-
switch self {
229-
case let .dotCom(_, siteId, _):
230-
return try Blog.lookup(withID: siteId, in: context)
231-
case let .selfHosted(blogId, _, _, _, _):
232-
return try context.existingObject(with: blogId)
322+
extension WordPressSite {
323+
/// The application password credentials, if available.
324+
/// Always non-nil for self-hosted sites. Optional for WordPress.com sites
325+
/// (non-nil for Atomic sites).
326+
public var applicationPasswordCredentials: ApplicationPasswordCredentials? {
327+
switch flavor {
328+
case let .dotCom(credentials):
329+
return credentials.applicationPassword
330+
case let .selfHosted(credentials):
331+
return credentials
233332
}
234333
}
235334

236-
public func blogId(in coreDataStack: CoreDataStack) -> TaggedManagedObjectID<Blog>? {
237-
switch self {
238-
case let .dotCom(_, siteId, _):
239-
return coreDataStack.performQuery { context in
240-
guard let blog = try? Blog.lookup(withID: siteId, in: context) else { return nil }
241-
return TaggedManagedObjectID(blog)
242-
}
243-
case let .selfHosted(id, _, _, _, _):
244-
return id
245-
}
335+
/// Look up the `Blog` object in a given Core Data context.
336+
public func blog(in context: NSManagedObjectContext) throws -> Blog {
337+
try context.existingObject(with: blogId)
246338
}
247339
}

Tests/KeystoneTests/Tests/Features/Posts/CustomPostEditorServiceTests.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,10 @@ private func makeService(
160160
) throws -> CustomPostEditorService {
161161
let api = try WordPressAPI(
162162
urlSession: .shared,
163-
apiRootUrl: .parse(input: "https://example.com/wp-json"),
163+
siteInfo: .selfHosted(
164+
siteUrl: .parse(input: "https://example.com"),
165+
apiRoot: .parse(input: "https://example.com/wp-json")
166+
),
164167
authentication: .none
165168
)
166169
let client = WordPressClient(

Tests/KeystoneTests/Tests/Services/UserListViewModelTests.swift

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,15 @@ class UserListViewModelTests: XCTestCase {
1616
override func setUp() async throws {
1717
try await super.setUp()
1818

19-
let api = try WordPressAPI(urlSession: .shared, apiRootUrl: .parse(input: "https://example.com/wp-json"), authentication: .none)
20-
let client = try WordPressClient(
19+
let api = try WordPressAPI(
20+
urlSession: .shared,
21+
siteInfo: .selfHosted(
22+
siteUrl: .parse(input: "https://example.com"),
23+
apiRoot: .parse(input: "https://example.com/wp-json")
24+
),
25+
authentication: .none
26+
)
27+
let client = WordPressClient(
2128
api: api,
2229
siteURL: URL(string: "https://example.com")!
2330
)

WordPress/Classes/Login/ApplicationPasswordRequiredView.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,9 @@ struct ApplicationPasswordRequiredView<Content: View>: View {
126126
}
127127

128128
private func updateSite() {
129-
// We check that the site is `selfHosted` to ensure an _Application Password_ is available. That's what this view
130-
// is for, after all.
131-
if let site = try? WordPressSite(blog: blog), case .selfHosted = site {
129+
// We check that the site has application password credentials to ensure
130+
// direct wp/v2 API access is available. That's what this view is for.
131+
if let site = try? WordPressSite(blog: blog), site.applicationPasswordCredentials != nil {
132132
self.site = site
133133
}
134134
}

WordPress/Classes/Login/SelfHostedSiteAuthenticator.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ struct SelfHostedSiteAuthenticator {
146146
}
147147

148148
let apiRootURL = details.apiRootUrl.asURL()
149-
let result = try await handle(credentials: credentials, apiRootURL: apiRootURL, apiDetails: details.apiDetails, context: context)
149+
let result = try await handle(credentials: credentials, apiRootURL: apiRootURL, apiDiscovery: details, context: context)
150150
trackSuccess(url: details.parsedSiteUrl.url())
151151
return result
152152
} catch {
@@ -205,7 +205,7 @@ struct SelfHostedSiteAuthenticator {
205205
}
206206

207207
@MainActor
208-
private func handle(credentials: WpApiApplicationPasswordDetails, apiRootURL: URL, apiDetails: WpApiDetails, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
208+
private func handle(credentials: WpApiApplicationPasswordDetails, apiRootURL: URL, apiDiscovery: AutoDiscoveryAttemptSuccess, context: SignInContext) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
209209
SVProgressHUD.show()
210210
defer {
211211
SVProgressHUD.dismiss()
@@ -215,7 +215,7 @@ struct SelfHostedSiteAuthenticator {
215215
throw .mismatchedUser(expectedUsername: username)
216216
}
217217

218-
let blog = try await createSite(credentials: credentials, apiRootURL: apiRootURL, apiDetails: apiDetails, context: context)
218+
let blog = try await createSite(credentials: credentials, apiRootURL: apiRootURL, apiDiscovery: apiDiscovery, context: context)
219219

220220
switch context {
221221
case .default:
@@ -257,7 +257,7 @@ struct SelfHostedSiteAuthenticator {
257257
private func createSite(
258258
credentials: WpApiApplicationPasswordDetails,
259259
apiRootURL: URL,
260-
apiDetails: WpApiDetails,
260+
apiDiscovery: AutoDiscoveryAttemptSuccess,
261261
context: SignInContext
262262
) async throws(SignInError) -> TaggedManagedObjectID<Blog> {
263263
// We still need to set the `Blog.xmlrpc`, because it's used all across the app.
@@ -269,7 +269,10 @@ struct SelfHostedSiteAuthenticator {
269269

270270
let api = WordPressAPI(
271271
urlSession: URLSession(configuration: .ephemeral),
272-
apiRootUrl: try! ParsedUrl.parse(input: apiRootURL.absoluteString),
272+
siteInfo: .selfHosted(
273+
siteUrl: apiDiscovery.parsedSiteUrl,
274+
apiRoot: apiDiscovery.apiRootUrl
275+
),
273276
authentication: WpAuthentication(username: credentials.userLogin, password: credentials.password)
274277
)
275278

@@ -335,12 +338,12 @@ struct SelfHostedSiteAuthenticator {
335338
blog.setValue(siteSettings.timezone, forOption: "timezone")
336339
}
337340

338-
if blog.getOptionString(name: "gmt_offset") == nil, let offset = apiDetails.gmtOffset() {
341+
if blog.getOptionString(name: "gmt_offset") == nil, let offset = apiDiscovery.apiDetails.gmtOffset() {
339342
blog.setValue(offset, forOption: "gmt_offset")
340343
}
341344

342345
if blog.getOptionString(name: "home_url") == nil {
343-
blog.setValue(apiDetails.homeUrlString(), forOption: "home_url")
346+
blog.setValue(apiDiscovery.apiDetails.homeUrlString(), forOption: "home_url")
344347
}
345348
}
346349

0 commit comments

Comments
 (0)