Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[v7] Make APIClient Non-optional & Update BTAmericanExpressClient Init #1527

Open
wants to merge 15 commits into
base: v7
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
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
4 changes: 4 additions & 0 deletions Braintree.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@
BE80C00D29C8B4B900793A6C /* BTCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE80C00C29C8B4B900793A6C /* BTCard.swift */; };
BE82E73B29C49C050059FE97 /* BTThreeDSecureV2ButtonCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E73A29C49C050059FE97 /* BTThreeDSecureV2ButtonCustomization.swift */; };
BE82E73F29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE82E73E29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift */; };
BE849EED2D66908E005BD215 /* InvalidAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */; };
BE895C61299433FB008112AB /* BTApplePayCardNonce.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C60299433FB008112AB /* BTApplePayCardNonce.swift */; };
BE895C6329944BD3008112AB /* BTApplePayError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C6229944BD3008112AB /* BTApplePayError.swift */; };
BE895C6529944BF5008112AB /* BTApplePayClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE895C6429944BF5008112AB /* BTApplePayClient.swift */; };
Expand Down Expand Up @@ -966,6 +967,7 @@
BE82E73C29C49ECE0059FE97 /* BTThreeDSecureV2LabelCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2LabelCustomization.swift; sourceTree = "<group>"; };
BE82E73E29C4A06B0059FE97 /* BTThreeDSecureV2ToolbarCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2ToolbarCustomization.swift; sourceTree = "<group>"; };
BE82E74029C4A1330059FE97 /* BTThreeDSecureV2TextBoxCustomization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTThreeDSecureV2TextBoxCustomization.swift; sourceTree = "<group>"; };
BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvalidAuthorization.swift; sourceTree = "<group>"; };
BE895C60299433FB008112AB /* BTApplePayCardNonce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayCardNonce.swift; sourceTree = "<group>"; };
BE895C6229944BD3008112AB /* BTApplePayError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayError.swift; sourceTree = "<group>"; };
BE895C6429944BF5008112AB /* BTApplePayClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BTApplePayClient.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1583,6 +1585,7 @@
80B207342BF6D3B000787E37 /* Authorization */ = {
isa = PBXGroup;
children = (
BE849EEC2D669088005BD215 /* InvalidAuthorization.swift */,
BED00CAF28A579D700D74AEC /* BTClientToken.swift */,
BED00CB128A57AD400D74AEC /* BTClientTokenError.swift */,
BE24C67428E7491E0067B11A /* ClientAuthorization.swift */,
Expand Down Expand Up @@ -3207,6 +3210,7 @@
BE2F98D028A2BCCD008EF189 /* BTConfiguration.swift in Sources */,
804DC45D2B2D08FF00F17A15 /* BTConfigurationRequest.swift in Sources */,
BED00CB228A57AD400D74AEC /* BTClientTokenError.swift in Sources */,
BE849EED2D66908E005BD215 /* InvalidAuthorization.swift in Sources */,
BE24C67328E73E810067B11A /* BTAPIClientHTTPType.swift in Sources */,
457D7FC82C29CEC300EF6523 /* RepeatingTimer.swift in Sources */,
BE9EC0982899CF040022EC63 /* BTAPIPinnedCertificates.swift in Sources */,
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
* Remove `fetchPaymentMethodNonces` methods and parser
* BraintreePayPal
* Update PayPal app URL query scheme from `paypal-app-switch-checkout` to `paypal`
* BraintreeAmericanExpress
* Update initializer to `BTAmericanExpressClient(authorization:)`

## 6.27.0 (2025-01-23)
* BraintreePayPal
Expand Down
3 changes: 3 additions & 0 deletions Demo/Application/Base/PaymentButtonBaseViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import BraintreeCore

class PaymentButtonBaseViewController: BaseViewController {

// TODO: remove API client in final PR
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added some TODOs throughout this PR to update in our final PR for this work

let apiClient: BTAPIClient
let authorization: String

var heightConstraint: CGFloat?

Expand All @@ -12,6 +14,7 @@ class PaymentButtonBaseViewController: BaseViewController {
override init(authorization: String) {
// swiftlint:disable:next force_unwrapping
apiClient = BTAPIClient(authorization: authorization)!
self.authorization = authorization
super.init(authorization: authorization)
}

Expand Down
2 changes: 1 addition & 1 deletion Demo/Application/Features/AmexViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import BraintreeCard

class AmexViewController: PaymentButtonBaseViewController {

lazy var amexClient = BTAmericanExpressClient(apiClient: apiClient)
lazy var amexClient = BTAmericanExpressClient(authorization: authorization)
lazy var cardClient = BTCardClient(apiClient: apiClient)

override func viewDidLoad() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class BraintreeAmexExpress_IntegrationTests: XCTestCase {
func testGetRewardsBalance_returnsResult() async {
let apiClient = BTAPIClient(authorization: BTIntegrationTestsConstants.sandboxClientTokenVersion3)!
let cardClient = BTCardClient(apiClient: apiClient)
let amexClient = BTAmericanExpressClient(apiClient: apiClient)
let amexClient = BTAmericanExpressClient(authorization: BTIntegrationTestsConstants.sandboxClientTokenVersion3)

let card = BTCard(
number: "371260714673002",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ViewController: UIViewController {
override func viewDidLoad() {
let apiClient = BTAPIClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")!

let amexClient = BTAmericanExpressClient(apiClient: apiClient)
let amexClient = BTAmericanExpressClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")
let applePayClient = BTApplePayClient(apiClient: apiClient)
let cardClient = BTCardClient(apiClient: apiClient)
let dataCollector = BTDataCollector(apiClient: apiClient)
Expand Down
2 changes: 1 addition & 1 deletion SampleApps/SPMTest/SPMTest/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ class ViewController: UIViewController {
override func viewDidLoad() {
let apiClient = BTAPIClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")!

let amexClient = BTAmericanExpressClient(apiClient: apiClient)
let amexClient = BTAmericanExpressClient(authorization: "sandbox_9dbg82cq_dcpspy2brwdjr3qn")
let applePayClient = BTApplePayClient(apiClient: apiClient)
let cardClient = BTCardClient(apiClient: apiClient)
let dataCollector = BTDataCollector(apiClient: apiClient)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,14 @@ import BraintreeCore
/// `BTAmericanExpressClient` enables you to look up the rewards balance of American Express cards.
@objc public class BTAmericanExpressClient: NSObject {

private let apiClient: BTAPIClient
/// exposed for testing
var apiClient: BTAPIClient

/// Creates an American Express client.
/// - Parameter apiClient: An instance of `BTAPIClient`
/// - Parameter authorization: Your client token or tokenization key
@objc(initWithAPIClient:)
public init(apiClient: BTAPIClient) {
self.apiClient = apiClient
public init(authorization: String) {
self.apiClient = BTAPIClient(newAuthorization: authorization)
}

/// Gets the rewards balance associated with a Braintree nonce. Only for American Express cards.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,5 @@ public protocol ClientAuthorization {
public enum AuthorizationType {
case tokenizationKey
case clientToken
case invalidAuthorization
}
20 changes: 20 additions & 0 deletions Sources/BraintreeCore/Authorization/InvalidAuthorization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// An invalid authorization type
class InvalidAuthorization: ClientAuthorization {

let type = AuthorizationType.invalidAuthorization
let configURL: URL
let bearer: String
let originalValue: String
Comment on lines +6 to +9
Copy link
Contributor

Choose a reason for hiding this comment

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

None of these values are actually going to be used / we're just setting these properties to conform to the protocol but never using them functionally.

I crossed this bridge when messing around with this in this branch. I wonder if there's a cleaner pattern.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Potentially but I do think there is some value in enforcing them for the valid auth types. Since they won't be used it seems harmless. We also do something similar on Android.


init(_ rawValue: String) {
self.bearer = rawValue
self.originalValue = rawValue

// swiftlint:disable force_unwrapping
/// This URL is never used in the SDK as we always return an error if the authorization type is `.invalidAuthorization`
/// before construting or using the `configURL` in any way
self.configURL = URL(string: "https://paypal.com")!
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class TokenizationKey: ClientAuthorization {
guard let configURL = TokenizationKey.baseURLFromTokenizationKey(rawValue) else {
throw TokenizationKeyError.invalid
}

self.configURL = configURL
}

Expand Down
87 changes: 73 additions & 14 deletions Sources/BraintreeCore/BTAPIClient.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation

// swiftlint:disable type_body_length file_length
/// This class acts as the entry point for accessing the Braintree APIs via common HTTP methods performed on API endpoints.
/// - Note: It also manages authentication via tokenization key and provides access to a merchant's gateway configuration.
@objcMembers public class BTAPIClient: NSObject, BTHTTPNetworkTiming {
Expand All @@ -12,7 +13,7 @@ import Foundation

/// The TokenizationKey or ClientToken used to authorize the APIClient
public var authorization: ClientAuthorization

/// Client metadata that is used for tracking the client session
public private(set) var metadata: BTClientMetadata

Expand All @@ -34,7 +35,7 @@ import Foundation
public init?(authorization: String) {
self.metadata = BTClientMetadata()

guard let authorizationType = Self.authorizationType(for: authorization) else { return nil }
let authorizationType = Self.authorizationType(for: authorization)

switch authorizationType {
case .tokenizationKey:
Expand All @@ -50,6 +51,8 @@ import Foundation
} catch {
return nil
}
case .invalidAuthorization:
return nil
}

let btHttp = BTHTTP(authorization: self.authorization)
Expand All @@ -65,6 +68,31 @@ import Foundation
// No-op
}
}

// TODO: rename param to authorization in final PR - set as newAuthorization currently since otherwise the two inits have the same signature
/// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time.
/// Initialize a new API client.
/// - Parameter authorization: Your tokenization key or client token.
@_documentation(visibility: private)
public init(newAuthorization: String) {
self.authorization = Self.authorization(from: newAuthorization)
self.metadata = BTClientMetadata()

let btHTTP = BTHTTP(authorization: self.authorization)
http = btHTTP
configurationLoader = ConfigurationLoader(http: btHTTP)

super.init()

analyticsService.setAPIClient(self)
http?.networkTimingDelegate = self

// Kickoff the background request to fetch the config
fetchOrReturnRemoteConfiguration { _, _ in
// No-op
}
}


// MARK: - Deinit

Expand Down Expand Up @@ -93,20 +121,28 @@ import Foundation
@_documentation(visibility: private)
public func fetchOrReturnRemoteConfiguration(_ completion: @escaping (BTConfiguration?, Error?) -> Void) {
// TODO: - Consider updating all feature clients to use async version of this method?

Task { @MainActor in
do {
let configuration = try await configurationLoader.getConfig()
setupHTTPCredentials(configuration)
completion(configuration, nil)
} catch {
completion(nil, error)

if authorization.type == .invalidAuthorization {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

On android we do this check in each request (POST, GET, etc). Since fetchOrReturnRemoteConfiguration is the first API call in our SDK it made sense to me to throw the error here instead vs adding to each of the HTTP/GraphQLHTTP methods. This will allow us to return an error before making any API calls in the SDK if the auth is invalid.

Copy link
Contributor

@scannillo scannillo Feb 21, 2025

Choose a reason for hiding this comment

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

We could miss something by nesting it within fetchOrReturnRemoteConfiguration. Actually, this PR as-is misses doing auth string validation for shopperInsightsClient.getRecommendedPaymentMethods() calls, since it hits apiClient.post() directly.

Copy link
Contributor

Choose a reason for hiding this comment

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

IMO it will be better to call the auth string checks explicitly, vs relying on fetchOrReturnRemoteConfiguration(), which is already kind of confusing

Copy link
Contributor Author

@jaxdesmarais jaxdesmarais Feb 21, 2025

Choose a reason for hiding this comment

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

Ah, good catch, yes we do miss that currently. By call the auth string checks explicitly what do you mean? We want to make the network request in fetchOrReturnRemoteConfiguration before returning an error and move these to the get/post/etc requests? ignore me

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, nvm we can still do them before that network call. Updated here: 15bed08 and checked that getRecommendedPaymentMethods would hit that block as well.

completion(nil, BTAPIClientError.invalidAuthorization(authorization.originalValue))
} else {
Task { @MainActor in
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain why we're dispatching to MainActor in our network layer? I feel like that should be up to the merchant when they get our async result from tokenize() with either an error or success

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is not a change in code, it's just moving this logic into an else. But our code expects fetch or return config to be run on the main thread according to this test:

do {
let configuration = try await configurationLoader.getConfig()
setupHTTPCredentials(configuration)
completion(configuration, nil)
} catch {
completion(nil, error)
}
}
}
}

@MainActor func fetchConfiguration() async throws -> BTConfiguration {
try await configurationLoader.getConfig()
if authorization.type == .invalidAuthorization {
throw BTAPIClientError.invalidAuthorization(authorization.originalValue)
} else {
try await configurationLoader.getConfig()
}
}

/// :nodoc: This method is exposed for internal Braintree use only. Do not use. It is not covered by Semantic Versioning and may change or be removed at any time.
Expand Down Expand Up @@ -291,9 +327,11 @@ import Foundation

// MARK: - Internal Static Methods

static func authorizationType(for authorization: String) -> AuthorizationType? {
static func authorizationType(for authorization: String) -> AuthorizationType {
let pattern: String = "([a-zA-Z0-9]+)_[a-zA-Z0-9]+_([a-zA-Z0-9_]+)"
guard let regularExpression = try? NSRegularExpression(pattern: pattern) else { return nil }
guard let regularExpression = try? NSRegularExpression(pattern: pattern) else {
return .invalidAuthorization
}

let tokenizationKeyMatch: NSTextCheckingResult? = regularExpression.firstMatch(
in: authorization,
Expand Down Expand Up @@ -335,14 +373,35 @@ import Foundation
}
}

func payPalAPIURL(forEnvironment environment: String) -> URL? {
private func payPalAPIURL(forEnvironment environment: String) -> URL? {
if environment.caseInsensitiveCompare("sandbox") == .orderedSame ||
environment.caseInsensitiveCompare("development") == .orderedSame {
return BTCoreConstants.payPalSandboxURL
} else {
return BTCoreConstants.payPalProductionURL
}
}

private static func authorization(from authorization: String) -> ClientAuthorization {
let authorizationType = Self.authorizationType(for: authorization)

switch authorizationType {
case .tokenizationKey:
do {
return try TokenizationKey(authorization)
} catch {
return InvalidAuthorization(authorization)
}
case .clientToken:
do {
return try BTClientToken(clientToken: authorization)
} catch {
return InvalidAuthorization(authorization)
}
case .invalidAuthorization:
return InvalidAuthorization(authorization)
}
}

// MARK: BTAPITimingDelegate conformance

Expand Down
21 changes: 19 additions & 2 deletions Sources/BraintreeCore/BTAPIClientError.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// Error codes associated with a API Client.
public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatable {
public enum BTAPIClientError: Error, CustomNSError, LocalizedError, Equatable {

/// 0. Configuration fetch failed
case configurationUnavailable
Expand All @@ -14,13 +14,27 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab

/// 3. Failed to base64 encode an authorizationFingerprint or tokenizationKey, when used as a cacheKey
case failedBase64Encoding

/// 4. Invalid authorization
case invalidAuthorization(String)

public static var errorDomain: String {
"com.braintreepayments.BTAPIClientErrorDomain"
}

public var errorCode: Int {
rawValue
switch self {
case .configurationUnavailable:
return 0
case .notAuthorized:
return 1
case .deallocated:
return 2
case .failedBase64Encoding:
return 3
case .invalidAuthorization:
return 4
}
}

public var errorDescription: String? {
Expand All @@ -36,6 +50,9 @@ public enum BTAPIClientError: Int, Error, CustomNSError, LocalizedError, Equatab

case .failedBase64Encoding:
return "Unable to base64 encode the authorization string."

case .invalidAuthorization(let authorization):
return "Invalid authorization provided: \(authorization)."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,12 @@ class BTAmericanExpressClient_Tests: XCTestCase {

override func setUp() {
super.setUp()
mockAPIClient = MockAPIClient(authorization: "development_tokenization_key")!
amexClient = BTAmericanExpressClient(apiClient: mockAPIClient)
amexClient = BTAmericanExpressClient(authorization: "development_tokenization_key")
amexClient?.apiClient = mockAPIClient
}

func testGetRewardsBalance_formatsGETRequest() async {
let result = try? await amexClient!.getRewardsBalance(forNonce: "fake-nonce", currencyISOCode: "fake-code")
let _ = try? await amexClient!.getRewardsBalance(forNonce: "fake-nonce", currencyISOCode: "fake-code")

XCTAssertEqual(mockAPIClient.lastGETPath, "v1/payment_methods/amex_rewards_balance")

Expand Down Expand Up @@ -91,4 +91,27 @@ class BTAmericanExpressClient_Tests: XCTestCase {
XCTAssertEqual(mockAPIClient.postedAnalyticsEvents[mockAPIClient.postedAnalyticsEvents.count - 2], "amex:rewards-balance:started")
XCTAssertEqual(mockAPIClient.postedAnalyticsEvents.last!, "amex:rewards-balance:failed")
}

func testGetRewardsBalance_withInvalidAuthorization_returnsError() {
amexClient = BTAmericanExpressClient(authorization: "badAuth")
mockAPIClient.cannedResponseError = NSError(
domain: BTAPIClientError.errorDomain,
code: BTAPIClientError.invalidAuthorization("").errorCode,
userInfo: [NSLocalizedDescriptionKey: BTAPIClientError.invalidAuthorization("").errorDescription ?? ""]
)

let expectation = expectation(description: "Amex reward balance should return invalid authorization error")
amexClient?.getRewardsBalance(forNonce: "", currencyISOCode: "") { rewardsBalance, error in
XCTAssertNil(rewardsBalance)
if let error = error as NSError? {
XCTAssertEqual(error.code, BTAPIClientError.invalidAuthorization("").errorCode)
XCTAssertEqual(error.localizedDescription, "Invalid authorization provided: badAuth.")
XCTAssertEqual(error.domain, BTAPIClientError.errorDomain)
}

expectation.fulfill()
}

waitForExpectations(timeout: 2)
}
}
Loading
Loading