Skip to content

Commit c9e80a2

Browse files
authored
Jetpack Setup: Add new connection endpoints to the remote (#15976)
2 parents 04022ac + f271e90 commit c9e80a2

13 files changed

+271
-6
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import Foundation
2+
3+
/// Mapper: Jetpack connection registration
4+
///
5+
/// periphery: ignore - used in `JetpackConnectionRemote`
6+
struct JetpackConnectionProvisionMapper: Mapper {
7+
8+
/// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response.
9+
///
10+
func map(response: Data) throws -> JetpackConnectionProvisionResponse {
11+
let decoder = JSONDecoder()
12+
decoder.keyDecodingStrategy = .convertFromSnakeCase
13+
return try decoder.decode(JetpackConnectionProvisionResponse.self, from: response)
14+
}
15+
}
16+
17+
public struct JetpackConnectionProvisionResponse: Decodable {
18+
public let userId: Int64
19+
public let scope: String
20+
public let secret: String
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import Foundation
2+
3+
/// Mapper: Jetpack connection registration
4+
///
5+
/// periphery: ignore - used in `JetpackConnectionRemote`
6+
struct JetpackConnectionRegistrationMapper: Mapper {
7+
8+
/// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response.
9+
///
10+
func map(response: Data) throws -> JetpackConnectionRegistration {
11+
let decoder = JSONDecoder()
12+
return try decoder.decode(JetpackConnectionRegistration.self, from: response)
13+
}
14+
}
15+
16+
struct JetpackConnectionRegistration: Decodable {
17+
let authorizeUrl: String
18+
}

Modules/Sources/Networking/Mapper/JetpackConnectionURLMapper.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ struct JetpackConnectionURLMapper: Mapper {
88
///
99
func map(response: Data) throws -> URL {
1010
guard let escapedString = String(data: response, encoding: .utf8) else {
11-
throw JetpackConnectionRemote.ConnectionError.malformedURL
11+
throw JetpackConnectionError.malformedURL
1212
}
1313
// The API returns an escaped string with double quotes, so we need to clean it up.
1414
let urlString = escapedString

Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,49 @@ public final class JetpackConnectionRemote: Remote {
5656
let mapper = JetpackConnectionDataMapper()
5757
enqueue(request, mapper: mapper, completion: completion)
5858
}
59-
}
6059

61-
public extension JetpackConnectionRemote {
62-
enum ConnectionError: Int, Error {
63-
case malformedURL
64-
case accountConnectionURLNotFound
60+
/// Establishes a site-level connection between the site and WordPress.com using Jetpack.
61+
/// Returns WPCom `blogID` of the connected site.
62+
/// periphery: ignore - used in `JetpackConnectionStore` later
63+
///
64+
public func registerSite() async throws -> Int64 {
65+
let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionRegister)
66+
let mapper = JetpackConnectionRegistrationMapper()
67+
let authorizationURL = try await enqueue(request, mapper: mapper).authorizeUrl
68+
guard let components = URLComponents(string: authorizationURL),
69+
let blogID = components.queryItems?.first(where: { $0.name == Constants.clientID })?.value as? String,
70+
let numericID = Int64(blogID) else {
71+
throw JetpackConnectionError.invalidAuthorizationURL
72+
}
73+
return numericID
6574
}
75+
76+
/// Provisions the connection between the site and WordPress.com using Jetpack.
77+
/// Returns a response containing scope and secret to be sent for finalizing the connection.
78+
/// periphery: ignore - used in `JetpackConnectionStore` later
79+
///
80+
public func provisionConnection() async throws -> JetpackConnectionProvisionResponse {
81+
let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionProvision)
82+
let mapper = JetpackConnectionProvisionMapper()
83+
return try await enqueue(request, mapper: mapper)
84+
}
85+
}
86+
87+
/// periphery: ignore - used in test module and on the UI layer
88+
public enum JetpackConnectionError: Error, Equatable {
89+
case malformedURL
90+
case accountConnectionURLNotFound
91+
case invalidAuthorizationURL
92+
case alreadyConnected
93+
case connectionRequestFailed(message: String)
6694
}
6795

6896
private extension JetpackConnectionRemote {
6997
enum Path {
7098
static let jetpackConnectionURL = "/jetpack/v4/connection/url"
7199
static let jetpackConnectionData = "/jetpack/v4/connection/data"
100+
static let jetpackConnectionRegister = "/jetpack/v4/connection/register"
101+
static let jetpackConnectionProvision = "/jetpack/v4/remote_provision"
72102
static let plugins = "/wp/v2/plugins"
73103
static let jetpackModule = "/jetpack/v4/module"
74104
}
@@ -83,5 +113,6 @@ private extension JetpackConnectionRemote {
83113
static let jetpackPluginName = "jetpack/jetpack"
84114
static let jetpackPluginSlug = "jetpack"
85115
static let activeStatus = "active"
116+
static let clientID = "client_id"
86117
}
87118
}

Modules/Sources/Networking/Remote/SiteRemote.swift

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,17 @@ public protocol SiteRemoteProtocol {
3737
/// - title: The new title to be set for the site
3838
///
3939
func updateSiteTitle(siteID: Int64, title: String) async throws
40+
41+
/// Finalizes the Jetpack connection by sending a request to WPCom.
42+
/// - Parameters:
43+
/// - siteID: Remote ID of the site
44+
/// - siteURL: URL of the site
45+
/// - provisionResponse: Response from the provision connection call
46+
/// periphery: ignore - used in test module
47+
///
48+
func finalizeJetpackConnection(siteID: Int64,
49+
siteURL: String,
50+
provisionResponse: JetpackConnectionProvisionResponse) async throws
4051
}
4152

4253
/// Site: Remote Endpoints
@@ -161,6 +172,41 @@ public class SiteRemote: Remote, SiteRemoteProtocol {
161172
availableAsRESTRequest: true)
162173
try await enqueue(request)
163174
}
175+
176+
/// Finalizes the Jetpack connection by sending a request to WPCom.
177+
/// periphery: ignore - used in `JetpackConnectionStore` later
178+
///
179+
public func finalizeJetpackConnection(siteID: Int64,
180+
siteURL: String,
181+
provisionResponse: JetpackConnectionProvisionResponse) async throws {
182+
let parameters: [String: Any] = [
183+
ParameterKey.secret: provisionResponse.secret,
184+
ParameterKey.scope: provisionResponse.scope,
185+
ParameterKey.externalUserID: provisionResponse.userId,
186+
ParameterKey.redirectURI: siteURL
187+
]
188+
let request = DotcomRequest(wordpressApiVersion: .wpcomMark2,
189+
method: .post,
190+
path: String(format: Path.jetpackConnection, siteID),
191+
parameters: parameters)
192+
do {
193+
try await enqueue(request)
194+
} catch let error as WordPressApiError {
195+
switch error {
196+
case let .unknown(code, message):
197+
if code == Constants.success {
198+
return
199+
} else if code == Constants.alreadyConnected {
200+
throw JetpackConnectionError.alreadyConnected
201+
}
202+
throw JetpackConnectionError.connectionRequestFailed(message: message)
203+
default:
204+
throw error
205+
}
206+
} catch {
207+
throw error
208+
}
209+
}
164210
}
165211

166212
/// Possible Site Creation Flows
@@ -329,8 +375,22 @@ private extension SiteRemote {
329375
static func siteSettings(siteID: Int64) -> String {
330376
"sites/\(siteID)/settings"
331377
}
378+
379+
static let jetpackConnection = "sites/%d/jetpack-remote-connect-user"
332380
}
333381
enum Fields {
334382
static let title = "title"
335383
}
384+
385+
enum Constants {
386+
static let success = "success"
387+
static let alreadyConnected = "already_connected"
388+
}
389+
390+
enum ParameterKey {
391+
static let secret = "secret"
392+
static let externalUserID = "external_user_id"
393+
static let redirectURI = "redirect_uri"
394+
static let scope = "scope"
395+
}
336396
}

Modules/Tests/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,80 @@ final class JetpackConnectionRemoteTests: XCTestCase {
214214
XCTAssertTrue(result.isFailure)
215215
XCTAssertEqual(result.failure as? NetworkError, error)
216216
}
217+
218+
func test_registerSite_correctly_returns_blogID() async throws {
219+
// Given
220+
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
221+
let urlSuffix = "/jetpack/v4/connection/register"
222+
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration")
223+
224+
// When
225+
let blogID = try await remote.registerSite()
226+
227+
// Then
228+
XCTAssertEqual(blogID, 1234567890)
229+
}
230+
231+
func test_registerSite_properly_relays_errors() async {
232+
// Given
233+
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
234+
let urlSuffix = "/jetpack/v4/connection/register"
235+
let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500)
236+
network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError)
237+
238+
do {
239+
// When
240+
_ = try await remote.registerSite()
241+
} catch {
242+
// Then
243+
XCTAssertEqual(error as? NetworkError, expectedError)
244+
}
245+
}
246+
247+
func test_registerSite_throws_invalidAuthorizationURL_error_for_malformed_URL() async {
248+
// Given
249+
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
250+
let urlSuffix = "/jetpack/v4/connection/register"
251+
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration-invalid")
252+
253+
do {
254+
// When
255+
_ = try await remote.registerSite()
256+
} catch {
257+
// Then
258+
XCTAssertEqual(error as? JetpackConnectionError, .invalidAuthorizationURL)
259+
}
260+
}
261+
262+
func test_provisionConnection_correctly_returns_provision_response() async throws {
263+
// Given
264+
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
265+
let urlSuffix = "/jetpack/v4/remote_provision"
266+
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-provision")
267+
268+
// When
269+
let response = try await remote.provisionConnection()
270+
271+
// Then
272+
XCTAssertEqual(response.userId, 123456789)
273+
XCTAssertEqual(response.scope, "administrator")
274+
XCTAssertEqual(response.secret, "secret_token_12345")
275+
}
276+
277+
func test_provisionConnection_properly_relays_errors() async {
278+
// Given
279+
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
280+
let urlSuffix = "/jetpack/v4/remote_provision"
281+
let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500)
282+
network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError)
283+
284+
do {
285+
// When
286+
_ = try await remote.provisionConnection()
287+
} catch {
288+
// Then
289+
XCTAssertEqual(error as? NetworkError, expectedError)
290+
}
291+
}
292+
217293
}

Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,39 @@ final class SiteRemoteTests: XCTestCase {
323323
message: "The Jetpack site is inaccessible or returned an error: parse error (local). not well formed [-32710]"
324324
)})
325325
}
326+
327+
// MARK: - `finalizeJetpackConnection`
328+
329+
func test_finalizeJetpackConnection_successfully_completes() async {
330+
// Given
331+
let siteID: Int64 = 12345
332+
let siteURL = "http://test.com"
333+
let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345")
334+
let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user"
335+
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-success")
336+
337+
// When & Then
338+
do {
339+
try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse)
340+
} catch {
341+
XCTFail("Unexpected failure: \(error)")
342+
}
343+
}
344+
345+
func test_finalizeJetpackConnection_properly_relays_errors() async {
346+
// Given
347+
let siteID: Int64 = 12345
348+
let siteURL = "http://test.com"
349+
let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345")
350+
let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user"
351+
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-error")
352+
353+
do {
354+
// When
355+
try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse)
356+
} catch {
357+
// Then
358+
XCTAssertEqual(error as? JetpackConnectionError, .alreadyConnected)
359+
}
360+
}
326361
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"code": "already_connected",
3+
"message": "Remote endpoint returned an error."
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"message": "Successfully connected user.",
3+
"code": "success"
4+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"user_id": 123456789,
3+
"scope": "administrator",
4+
"secret": "secret_token_12345"
5+
}

0 commit comments

Comments
 (0)