Skip to content

Commit fe3f198

Browse files
Merge pull request #8483 from woocommerce/feat/8482-alamofire-retry-adapt-request
[REST API] Alamofire network retry and adapt network requests
2 parents 018d02a + c66416d commit fe3f198

25 files changed

+803
-338
lines changed

Networking/Networking.xcodeproj/project.pbxproj

Lines changed: 40 additions & 12 deletions
Large diffs are not rendered by default.

Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import enum Alamofire.AFError
66
enum ApplicationPasswordUseCaseError: Error {
77
case duplicateName
88
case applicationPasswordsDisabled
9-
case invalidSiteAddress
9+
case failedToConstructLoginOrAdminURLUsingSiteAddress
1010
}
1111

1212
struct ApplicationPassword {
@@ -77,7 +77,7 @@ final class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase {
7777
guard let loginURL = URL(string: siteAddress + Constants.loginPath),
7878
let adminURL = URL(string: siteAddress + Constants.adminPath) else {
7979
DDLogWarn("⚠️ Cannot construct login URL and admin URL for site \(siteAddress)")
80-
throw ApplicationPasswordUseCaseError.invalidSiteAddress
80+
throw ApplicationPasswordUseCaseError.failedToConstructLoginOrAdminURLUsingSiteAddress
8181
}
8282
// Prepares the authenticator with username and password
8383
let authenticator = CookieNonceAuthenticator(username: username,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
enum RequestAuthenticatorError: Error {
2+
case applicationPasswordUseCaseNotAvailable
3+
case applicationPasswordNotAvailable
4+
}
5+
6+
/// Authenticates request
7+
///
8+
public struct RequestAuthenticator {
9+
/// Credentials.
10+
///
11+
let credentials: Credentials?
12+
13+
/// The use case to handle authentication with application passwords.
14+
///
15+
private let applicationPasswordUseCase: ApplicationPasswordUseCase?
16+
17+
/// Sets up the authenticator with optional credentials and application password use case.
18+
/// `applicationPasswordUseCase` can be injected for unit tests.
19+
///
20+
init(credentials: Credentials?, applicationPasswordUseCase: ApplicationPasswordUseCase? = nil) {
21+
self.credentials = credentials
22+
let useCase: ApplicationPasswordUseCase? = {
23+
if let applicationPasswordUseCase {
24+
return applicationPasswordUseCase
25+
} else if case let .wporg(username, password, siteAddress) = credentials {
26+
return try? DefaultApplicationPasswordUseCase(username: username,
27+
password: password,
28+
siteAddress: siteAddress)
29+
} else {
30+
return nil
31+
}
32+
}()
33+
self.applicationPasswordUseCase = useCase
34+
}
35+
36+
func authenticate(_ urlRequest: URLRequest) throws -> URLRequest {
37+
if isRestAPIRequest(urlRequest) {
38+
return try authenticateUsingApplicationPasswordIfPossible(urlRequest)
39+
} else {
40+
return try authenticateUsingWPCOMTokenIfPossible(urlRequest)
41+
}
42+
}
43+
44+
func generateApplicationPassword() async throws {
45+
guard let applicationPasswordUseCase else {
46+
throw RequestAuthenticatorError.applicationPasswordUseCaseNotAvailable
47+
}
48+
let _ = try await applicationPasswordUseCase.generateNewPassword()
49+
return
50+
}
51+
52+
/// Checks whether the given URLRequest is eligible for retyring
53+
///
54+
func shouldRetry(_ urlRequest: URLRequest) -> Bool {
55+
isRestAPIRequest(urlRequest)
56+
}
57+
}
58+
59+
private extension RequestAuthenticator {
60+
/// To check whether the given URLRequest is a REST API request
61+
///
62+
func isRestAPIRequest(_ urlRequest: URLRequest) -> Bool {
63+
guard case let .wporg(_, _, siteAddress) = credentials,
64+
let url = urlRequest.url,
65+
url.absoluteString.hasPrefix(siteAddress.trimSlashes() + "/" + RESTRequest.Settings.basePath) else {
66+
return false
67+
}
68+
return true
69+
}
70+
71+
/// Attempts creating a request with WPCOM token if possible.
72+
///
73+
func authenticateUsingWPCOMTokenIfPossible(_ urlRequest: URLRequest) throws -> URLRequest {
74+
guard case let .wpcom(_, authToken, _) = credentials else {
75+
return UnauthenticatedRequest(request: urlRequest).asURLRequest()
76+
}
77+
78+
return AuthenticatedDotcomRequest(authToken: authToken, request: urlRequest).asURLRequest()
79+
}
80+
81+
/// Attempts creating a request with application password if possible.
82+
///
83+
func authenticateUsingApplicationPasswordIfPossible(_ urlRequest: URLRequest) throws -> URLRequest {
84+
guard let applicationPassword = applicationPasswordUseCase?.applicationPassword else {
85+
throw RequestAuthenticatorError.applicationPasswordNotAvailable
86+
}
87+
88+
return AuthenticatedRESTRequest(applicationPassword: applicationPassword, request: urlRequest).asURLRequest()
89+
}
90+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Alamofire
2+
3+
/// Converter to convert Jetpack tunnel requests into REST API requests if needed
4+
///
5+
struct RequestConverter {
6+
let credentials: Credentials?
7+
8+
func convert(_ request: URLRequestConvertible) -> URLRequestConvertible {
9+
guard let jetpackRequest = request as? JetpackRequest,
10+
case let .wporg(_, _, siteAddress) = credentials,
11+
let restRequest = jetpackRequest.asRESTRequest(with: siteAddress) else {
12+
return request
13+
}
14+
15+
return restRequest
16+
}
17+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import Alamofire
2+
import Foundation
3+
4+
/// Authenticates and retries requests
5+
///
6+
final class RequestProcessor {
7+
private var requestsToRetry = [RequestRetryCompletion]()
8+
9+
private var isAuthenticating = false
10+
11+
private let requestAuthenticator: RequestAuthenticator
12+
13+
init(credentials: Credentials?) {
14+
requestAuthenticator = RequestAuthenticator(credentials: credentials)
15+
}
16+
}
17+
18+
// MARK: Request Authentication
19+
//
20+
extension RequestProcessor: RequestAdapter {
21+
func adapt(_ urlRequest: URLRequest) throws -> URLRequest {
22+
return try requestAuthenticator.authenticate(urlRequest)
23+
}
24+
}
25+
26+
// MARK: Retrying Request
27+
//
28+
extension RequestProcessor: RequestRetrier {
29+
func should(_ manager: Alamofire.SessionManager,
30+
retry request: Alamofire.Request,
31+
with error: Error,
32+
completion: @escaping Alamofire.RequestRetryCompletion) {
33+
guard
34+
request.retryCount == 0, // Only retry once
35+
let urlRequest = request.request,
36+
requestAuthenticator.shouldRetry(urlRequest), // Retry only REST API requests that use application password
37+
shouldRetry(error) // Retry only specific errors
38+
else {
39+
return completion(false, 0.0)
40+
}
41+
42+
requestsToRetry.append(completion)
43+
if !isAuthenticating {
44+
generateApplicationPassword()
45+
}
46+
}
47+
}
48+
49+
// MARK: Helpers
50+
//
51+
private extension RequestProcessor {
52+
func generateApplicationPassword() {
53+
Task(priority: .medium) {
54+
isAuthenticating = true
55+
56+
do {
57+
let _ = try await requestAuthenticator.generateApplicationPassword()
58+
isAuthenticating = false
59+
completeRequests(true)
60+
} catch {
61+
isAuthenticating = false
62+
completeRequests(false)
63+
}
64+
}
65+
}
66+
67+
func shouldRetry(_ error: Error) -> Bool {
68+
// Need to generate application password
69+
if .applicationPasswordNotAvailable == error as? RequestAuthenticatorError {
70+
return true
71+
}
72+
73+
// Failed authorization
74+
if case .responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) = error as? AFError {
75+
return true
76+
}
77+
78+
return false
79+
}
80+
81+
func completeRequests(_ shouldRetry: Bool) {
82+
requestsToRetry.forEach { (completion) in
83+
completion(shouldRetry, 0.0)
84+
}
85+
requestsToRetry.removeAll()
86+
}
87+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import Foundation
2+
3+
extension String {
4+
/// Trims forward slash
5+
///
6+
/// - Returns: String after removing prefix and suffix "/"
7+
///
8+
func trimSlashes() -> String {
9+
removingPrefix("/").removingSuffix("/")
10+
}
11+
}

0 commit comments

Comments
 (0)