Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import Foundation

/// Mapper: Jetpack connection registration
///
/// periphery: ignore - used in `JetpackConnectionRemote`
struct JetpackConnectionProvisionMapper: Mapper {

/// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response.
///
func map(response: Data) throws -> JetpackConnectionProvisionResponse {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(JetpackConnectionProvisionResponse.self, from: response)
}
}

public struct JetpackConnectionProvisionResponse: Decodable {
public let userId: Int64
public let scope: String
public let secret: String
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Mapper: Jetpack connection registration
///
/// periphery: ignore - used in `JetpackConnectionRemote`
struct JetpackConnectionRegistrationMapper: Mapper {

/// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response.
///
func map(response: Data) throws -> JetpackConnectionRegistration {
let decoder = JSONDecoder()
return try decoder.decode(JetpackConnectionRegistration.self, from: response)
}
}

struct JetpackConnectionRegistration: Decodable {
let authorizeUrl: String
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ struct JetpackConnectionURLMapper: Mapper {
///
func map(response: Data) throws -> URL {
guard let escapedString = String(data: response, encoding: .utf8) else {
throw JetpackConnectionRemote.ConnectionError.malformedURL
throw JetpackConnectionError.malformedURL
}
// The API returns an escaped string with double quotes, so we need to clean it up.
let urlString = escapedString
Expand Down
41 changes: 36 additions & 5 deletions Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,19 +56,49 @@ public final class JetpackConnectionRemote: Remote {
let mapper = JetpackConnectionDataMapper()
enqueue(request, mapper: mapper, completion: completion)
}
}

public extension JetpackConnectionRemote {
enum ConnectionError: Int, Error {
case malformedURL
case accountConnectionURLNotFound
/// Establishes a site-level connection between the site and WordPress.com using Jetpack.
/// Returns WPCom `blogID` of the connected site.
/// periphery: ignore - used in `JetpackConnectionStore` later
///
public func registerSite() async throws -> Int64 {
let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionRegister)
let mapper = JetpackConnectionRegistrationMapper()
let authorizationURL = try await enqueue(request, mapper: mapper).authorizeUrl
guard let components = URLComponents(string: authorizationURL),
let blogID = components.queryItems?.first(where: { $0.name == Constants.clientID })?.value as? String,
let numericID = Int64(blogID) else {
throw JetpackConnectionError.invalidAuthorizationURL
}
return numericID
}

/// Provisions the connection between the site and WordPress.com using Jetpack.
/// Returns a response containing scope and secret to be sent for finalizing the connection.
/// periphery: ignore - used in `JetpackConnectionStore` later
///
public func provisionConnection() async throws -> JetpackConnectionProvisionResponse {
let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionProvision)
let mapper = JetpackConnectionProvisionMapper()
return try await enqueue(request, mapper: mapper)
}
}

/// periphery: ignore - used in test module and on the UI layer
public enum JetpackConnectionError: Error, Equatable {
case malformedURL
case accountConnectionURLNotFound
case invalidAuthorizationURL
case alreadyConnected
case connectionRequestFailed(message: String)
}

private extension JetpackConnectionRemote {
enum Path {
static let jetpackConnectionURL = "/jetpack/v4/connection/url"
static let jetpackConnectionData = "/jetpack/v4/connection/data"
static let jetpackConnectionRegister = "/jetpack/v4/connection/register"
static let jetpackConnectionProvision = "/jetpack/v4/remote_provision"
static let plugins = "/wp/v2/plugins"
static let jetpackModule = "/jetpack/v4/module"
}
Expand All @@ -83,5 +113,6 @@ private extension JetpackConnectionRemote {
static let jetpackPluginName = "jetpack/jetpack"
static let jetpackPluginSlug = "jetpack"
static let activeStatus = "active"
static let clientID = "client_id"
}
}
60 changes: 60 additions & 0 deletions Modules/Sources/Networking/Remote/SiteRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,17 @@ public protocol SiteRemoteProtocol {
/// - title: The new title to be set for the site
///
func updateSiteTitle(siteID: Int64, title: String) async throws

/// Finalizes the Jetpack connection by sending a request to WPCom.
/// - Parameters:
/// - siteID: Remote ID of the site
/// - siteURL: URL of the site
/// - provisionResponse: Response from the provision connection call
/// periphery: ignore - used in test module
///
func finalizeJetpackConnection(siteID: Int64,
siteURL: String,
provisionResponse: JetpackConnectionProvisionResponse) async throws
}

/// Site: Remote Endpoints
Expand Down Expand Up @@ -161,6 +172,41 @@ public class SiteRemote: Remote, SiteRemoteProtocol {
availableAsRESTRequest: true)
try await enqueue(request)
}

/// Finalizes the Jetpack connection by sending a request to WPCom.
/// periphery: ignore - used in `JetpackConnectionStore` later
///
public func finalizeJetpackConnection(siteID: Int64,
siteURL: String,
provisionResponse: JetpackConnectionProvisionResponse) async throws {
let parameters: [String: Any] = [
ParameterKey.secret: provisionResponse.secret,
ParameterKey.scope: provisionResponse.scope,
ParameterKey.externalUserID: provisionResponse.userId,
ParameterKey.redirectURI: siteURL
]
let request = DotcomRequest(wordpressApiVersion: .wpcomMark2,
method: .post,
path: String(format: Path.jetpackConnection, siteID),
parameters: parameters)
do {
try await enqueue(request)
} catch let error as WordPressApiError {
switch error {
case let .unknown(code, message):
if code == Constants.success {
return
} else if code == Constants.alreadyConnected {
throw JetpackConnectionError.alreadyConnected
}
throw JetpackConnectionError.connectionRequestFailed(message: message)
default:
throw error
}
} catch {
throw error
}
}
}

/// Possible Site Creation Flows
Expand Down Expand Up @@ -329,8 +375,22 @@ private extension SiteRemote {
static func siteSettings(siteID: Int64) -> String {
"sites/\(siteID)/settings"
}

static let jetpackConnection = "sites/%d/jetpack-remote-connect-user"
}
enum Fields {
static let title = "title"
}

enum Constants {
static let success = "success"
static let alreadyConnected = "already_connected"
}

enum ParameterKey {
static let secret = "secret"
static let externalUserID = "external_user_id"
static let redirectURI = "redirect_uri"
static let scope = "scope"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,80 @@ final class JetpackConnectionRemoteTests: XCTestCase {
XCTAssertTrue(result.isFailure)
XCTAssertEqual(result.failure as? NetworkError, error)
}

func test_registerSite_correctly_returns_blogID() async throws {
// Given
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
let urlSuffix = "/jetpack/v4/connection/register"
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration")

// When
let blogID = try await remote.registerSite()

// Then
XCTAssertEqual(blogID, 1234567890)
}

func test_registerSite_properly_relays_errors() async {
// Given
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
let urlSuffix = "/jetpack/v4/connection/register"
let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500)
network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError)

do {
// When
_ = try await remote.registerSite()
} catch {
// Then
XCTAssertEqual(error as? NetworkError, expectedError)
}
}

func test_registerSite_throws_invalidAuthorizationURL_error_for_malformed_URL() async {
// Given
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
let urlSuffix = "/jetpack/v4/connection/register"
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration-invalid")

do {
// When
_ = try await remote.registerSite()
} catch {
// Then
XCTAssertEqual(error as? JetpackConnectionError, .invalidAuthorizationURL)
}
}

func test_provisionConnection_correctly_returns_provision_response() async throws {
// Given
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
let urlSuffix = "/jetpack/v4/remote_provision"
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-provision")

// When
let response = try await remote.provisionConnection()

// Then
XCTAssertEqual(response.userId, 123456789)
XCTAssertEqual(response.scope, "administrator")
XCTAssertEqual(response.secret, "secret_token_12345")
}

func test_provisionConnection_properly_relays_errors() async {
// Given
let remote = JetpackConnectionRemote(siteURL: siteURL, network: network)
let urlSuffix = "/jetpack/v4/remote_provision"
let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500)
network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError)

do {
// When
_ = try await remote.provisionConnection()
} catch {
// Then
XCTAssertEqual(error as? NetworkError, expectedError)
}
}

}
35 changes: 35 additions & 0 deletions Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -323,4 +323,39 @@ final class SiteRemoteTests: XCTestCase {
message: "The Jetpack site is inaccessible or returned an error: parse error (local). not well formed [-32710]"
)})
}

// MARK: - `finalizeJetpackConnection`

func test_finalizeJetpackConnection_successfully_completes() async {
// Given
let siteID: Int64 = 12345
let siteURL = "http://test.com"
let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345")
let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user"
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-success")

// When & Then
do {
try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse)
} catch {
XCTFail("Unexpected failure: \(error)")
}
}

func test_finalizeJetpackConnection_properly_relays_errors() async {
// Given
let siteID: Int64 = 12345
let siteURL = "http://test.com"
let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345")
let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user"
network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-error")

do {
// When
try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse)
} catch {
// Then
XCTAssertEqual(error as? JetpackConnectionError, .alreadyConnected)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"code": "already_connected",
"message": "Remote endpoint returned an error."
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"message": "Successfully connected user.",
"code": "success"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"user_id": 123456789,
"scope": "administrator",
"secret": "secret_token_12345"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"authorizeUrl": "https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=12345&redirect_uri=http://test.com"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"alternateAuthorizeUrl":"",
"authorizeUrl":"https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=1234567890&redirect_uri=https://example.com"
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ final class MockSiteRemote {
}

extension MockSiteRemote: SiteRemoteProtocol {
func finalizeJetpackConnection(siteID: Int64, siteURL: String, provisionResponse: JetpackConnectionProvisionResponse) async throws {
// no-op
}

func createSite(name: String, flow: SiteCreationFlow) async throws -> SiteCreationResponse {
guard let result = createSiteResult else {
XCTFail("Could not find result for creating a site.")
Expand Down