BTAPIClient.m 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. #import "BTAnalyticsMetadata.h"
  2. #import "BTAnalyticsService.h"
  3. #import "BTAPIClient_Internal.h"
  4. #import "BTClientToken.h"
  5. #import "BTLogger_Internal.h"
  6. #import "BTPaymentMethodNonce.h"
  7. #import "BTPaymentMethodNonceParser.h"
  8. NSString *const BTAPIClientErrorDomain = @"com.braintreepayments.BTAPIClientErrorDomain";
  9. @interface BTAPIClient ()
  10. @property (nonatomic, strong) dispatch_queue_t configurationQueue;
  11. @end
  12. @implementation BTAPIClient
  13. - (nullable instancetype)initWithAuthorization:(NSString *)authorization {
  14. return [self initWithAuthorization:authorization sendAnalyticsEvent:YES];
  15. }
  16. - (nullable instancetype)initWithAuthorization:(NSString *)authorization sendAnalyticsEvent:(BOOL)sendAnalyticsEvent {
  17. if(![authorization isKindOfClass:[NSString class]]) {
  18. NSString *reason = @"BTClient could not initialize because the provided authorization was invalid";
  19. [[BTLogger sharedLogger] error:reason];
  20. return nil;
  21. }
  22. if (self = [super init]) {
  23. BTAPIClientAuthorizationType authorizationType = [[self class] authorizationTypeForAuthorization:authorization];
  24. switch (authorizationType) {
  25. case BTAPIClientAuthorizationTypeTokenizationKey: {
  26. NSURL *baseURL = [BTAPIClient baseURLFromTokenizationKey:authorization];
  27. if (!baseURL) {
  28. NSString *reason = @"BTClient could not initialize because the provided tokenization key was invalid";
  29. [[BTLogger sharedLogger] error:reason];
  30. return nil;
  31. }
  32. _tokenizationKey = authorization;
  33. _configurationHTTP = [[BTHTTP alloc] initWithBaseURL:baseURL tokenizationKey:authorization];
  34. if (sendAnalyticsEvent) {
  35. [self queueAnalyticsEvent:@"ios.started.client-key"];
  36. }
  37. break;
  38. }
  39. case BTAPIClientAuthorizationTypeClientToken: {
  40. NSError *error;
  41. _clientToken = [[BTClientToken alloc] initWithClientToken:authorization error:&error];
  42. if (error) { [[BTLogger sharedLogger] error:[error localizedDescription]]; }
  43. if (!_clientToken) {
  44. [[BTLogger sharedLogger] error:@"BTClient could not initialize because the provided clientToken was invalid"];
  45. return nil;
  46. }
  47. _configurationHTTP = [[BTHTTP alloc] initWithClientToken:self.clientToken];
  48. if (sendAnalyticsEvent) {
  49. [self queueAnalyticsEvent:@"ios.started.client-token"];
  50. }
  51. break;
  52. }
  53. case BTAPIClientAuthorizationTypePayPalIDToken: {
  54. NSError *error;
  55. _payPalIDToken = [[BTPayPalIDToken alloc] initWithIDTokenString:authorization error:&error];
  56. if (!_payPalIDToken || error) {
  57. [[BTLogger sharedLogger] error:@"BTClient could not initialize because the provided PayPal ID Token was invalid"];
  58. [[BTLogger sharedLogger] error:[error localizedDescription]];
  59. return nil;
  60. }
  61. _configurationHTTP = [[BTHTTP alloc] initWithPayPalIDToken:_payPalIDToken];
  62. if (sendAnalyticsEvent) {
  63. [self queueAnalyticsEvent:@"ios.started.paypal-id-token"];
  64. }
  65. break;
  66. }
  67. }
  68. _metadata = [[BTClientMetadata alloc] init];
  69. _configurationQueue = dispatch_queue_create("com.braintreepayments.BTAPIClient", DISPATCH_QUEUE_SERIAL);
  70. // BTHTTP's default NSURLSession does not cache responses, but we want the BTHTTP instance that fetches configuration to cache aggressively
  71. NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
  72. static NSURLCache *configurationCache;
  73. static dispatch_once_t onceToken;
  74. dispatch_once(&onceToken, ^{
  75. configurationCache = [[NSURLCache alloc] initWithMemoryCapacity:1 * 1024 * 1024 diskCapacity:0 diskPath:nil];
  76. });
  77. configuration.URLCache = configurationCache;
  78. configuration.requestCachePolicy = NSURLRequestReturnCacheDataElseLoad;
  79. _configurationHTTP.session = [NSURLSession sessionWithConfiguration:configuration];
  80. // Kickoff the background request to fetch the config
  81. [self fetchOrReturnRemoteConfiguration:^(__unused BTConfiguration * _Nullable configuration, __unused NSError * _Nullable error) {
  82. //noop
  83. }];
  84. }
  85. return self;
  86. }
  87. + (BTAPIClientAuthorizationType)authorizationTypeForAuthorization:(NSString *)authorization {
  88. NSRegularExpression *isTokenizationKeyRegExp = [NSRegularExpression regularExpressionWithPattern:@"^[a-zA-Z0-9]+_[a-zA-Z0-9]+_[a-zA-Z0-9_]+$" options:0 error:NULL];
  89. NSTextCheckingResult *tokenizationKeyMatch = [isTokenizationKeyRegExp firstMatchInString:authorization options:0 range: NSMakeRange(0, authorization.length)];
  90. NSRegularExpression *isPayPalIDTokenRegExp = [NSRegularExpression regularExpressionWithPattern:@"^[a-zA-Z0-9]+\\.[a-zA-Z0-9]+\\.[a-zA-Z0-9_-]+$" options:0 error:NULL];
  91. NSTextCheckingResult *payPalIDTokenMatch = [isPayPalIDTokenRegExp firstMatchInString:authorization options:0 range: NSMakeRange(0, authorization.length)];
  92. if (tokenizationKeyMatch) {
  93. return BTAPIClientAuthorizationTypeTokenizationKey;
  94. } else if (payPalIDTokenMatch) {
  95. return BTAPIClientAuthorizationTypePayPalIDToken;
  96. } else {
  97. return BTAPIClientAuthorizationTypeClientToken;
  98. }
  99. }
  100. - (instancetype)copyWithSource:(BTClientMetadataSourceType)source
  101. integration:(BTClientMetadataIntegrationType)integration
  102. {
  103. BTAPIClient *copiedClient;
  104. if (self.clientToken) {
  105. copiedClient = [[[self class] alloc] initWithAuthorization:self.clientToken.originalValue sendAnalyticsEvent:NO];
  106. } else if (self.tokenizationKey) {
  107. copiedClient = [[[self class] alloc] initWithAuthorization:self.tokenizationKey sendAnalyticsEvent:NO];
  108. } else if (self.payPalIDToken) {
  109. copiedClient = [[[self class] alloc] initWithAuthorization:self.payPalIDToken.token sendAnalyticsEvent:NO];
  110. } else {
  111. NSAssert(NO, @"Cannot copy an API client that does not specify a client token or tokenization key");
  112. }
  113. if (copiedClient) {
  114. BTMutableClientMetadata *mutableMetadata = [self.metadata mutableCopy];
  115. mutableMetadata.source = source;
  116. mutableMetadata.integration = integration;
  117. copiedClient->_metadata = [mutableMetadata copy];
  118. }
  119. return copiedClient;
  120. }
  121. #pragma mark - Base URL
  122. /// Gets base URL from tokenization key
  123. ///
  124. /// @param tokenizationKey The tokenization key
  125. ///
  126. /// @return Base URL for environment, or `nil` if tokenization key is invalid
  127. + (NSURL *)baseURLFromTokenizationKey:(NSString *)tokenizationKey {
  128. NSRegularExpression *regExp = [NSRegularExpression regularExpressionWithPattern:@"([a-zA-Z0-9]+)_[a-zA-Z0-9]+_([a-zA-Z0-9_]+)" options:0 error:NULL];
  129. NSArray *results = [regExp matchesInString:tokenizationKey options:0 range:NSMakeRange(0, tokenizationKey.length)];
  130. if (results.count != 1 || [[results firstObject] numberOfRanges] != 3) {
  131. return nil;
  132. }
  133. NSString *environment = [tokenizationKey substringWithRange:[results[0] rangeAtIndex:1]];
  134. NSString *merchantID = [tokenizationKey substringWithRange:[results[0] rangeAtIndex:2]];
  135. NSURLComponents *components = [[NSURLComponents alloc] init];
  136. components.scheme = [BTAPIClient schemeForEnvironmentString:environment];
  137. NSString *host = [BTAPIClient hostForEnvironmentString:environment];
  138. NSArray <NSString *> *hostComponents = [host componentsSeparatedByString:@":"];
  139. components.host = hostComponents[0];
  140. if (hostComponents.count > 1) {
  141. NSString *portString = hostComponents[1];
  142. components.port = @(portString.integerValue);
  143. }
  144. components.path = [BTAPIClient clientApiBasePathForMerchantID:merchantID];
  145. if (!components.host || !components.path) {
  146. return nil;
  147. }
  148. return components.URL;
  149. }
  150. + (NSString *)schemeForEnvironmentString:(NSString *)environment {
  151. if ([[environment lowercaseString] isEqualToString:@"development"]) {
  152. return @"http";
  153. }
  154. return @"https";
  155. }
  156. + (NSString *)hostForEnvironmentString:(NSString *)environment {
  157. if ([[environment lowercaseString] isEqualToString:@"sandbox"]) {
  158. return @"api.sandbox.braintreegateway.com";
  159. } else if ([[environment lowercaseString] isEqualToString:@"production"]) {
  160. return @"api.braintreegateway.com:443";
  161. } else if ([[environment lowercaseString] isEqualToString:@"development"]) {
  162. return @"localhost:3000";
  163. } else {
  164. return nil;
  165. }
  166. }
  167. + (NSURL *)graphQLURLForEnvironment:(NSString *)environment {
  168. NSURLComponents *components = [[NSURLComponents alloc] init];
  169. components.scheme = [BTAPIClient schemeForEnvironmentString:environment];
  170. NSString *host = [BTAPIClient graphQLHostForEnvironmentString:environment];
  171. NSArray <NSString *> *hostComponents = [host componentsSeparatedByString:@":"];
  172. if (hostComponents.count == 0) {
  173. return nil;
  174. }
  175. components.host = hostComponents[0];
  176. if (hostComponents.count > 1) {
  177. NSString *portString = hostComponents[1];
  178. components.port = @(portString.integerValue);
  179. }
  180. components.path = @"/graphql";
  181. return components.URL;
  182. }
  183. + (NSString *)graphQLHostForEnvironmentString:(NSString *)environment {
  184. if ([[environment lowercaseString] isEqualToString:@"sandbox"]) {
  185. return @"payments.sandbox.braintree-api.com";
  186. } else if ([[environment lowercaseString] isEqualToString:@"development"]) {
  187. return @"localhost:8080";
  188. } else {
  189. return @"payments.braintree-api.com";
  190. }
  191. }
  192. + (NSString *)clientApiBasePathForMerchantID:(NSString *)merchantID {
  193. if (merchantID.length == 0) {
  194. return nil;
  195. }
  196. return [NSString stringWithFormat:@"/merchants/%@/client_api", merchantID];
  197. }
  198. # pragma mark - Payment Methods
  199. - (void)fetchPaymentMethodNonces:(void (^)(NSArray <BTPaymentMethodNonce *> *, NSError *))completion {
  200. [self fetchPaymentMethodNonces:NO completion:completion];
  201. }
  202. - (void)fetchPaymentMethodNonces:(BOOL)defaultFirst completion:(void (^)(NSArray <BTPaymentMethodNonce *> *, NSError *))completion {
  203. if (!self.clientToken) {
  204. NSError *error = [NSError errorWithDomain:BTAPIClientErrorDomain code:BTAPIClientErrorTypeNotAuthorized userInfo:@{ NSLocalizedDescriptionKey : @"Cannot fetch payment method nonces with a tokenization key", NSLocalizedRecoverySuggestionErrorKey : @"This endpoint requires a client token for authorization"}];
  205. if (completion) {
  206. completion(nil, error);
  207. }
  208. return;
  209. }
  210. NSString *defaultFirstValue = defaultFirst ? @"true" : @"false";
  211. [self GET:@"v1/payment_methods"
  212. parameters:@{@"default_first": defaultFirstValue,
  213. @"session_id": self.metadata.sessionId}
  214. completion:^(BTJSON * _Nullable body, __unused NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
  215. dispatch_async(dispatch_get_main_queue(), ^{
  216. if (completion) {
  217. if (error) {
  218. completion(nil, error);
  219. } else {
  220. NSMutableArray *paymentMethodNonces = [NSMutableArray array];
  221. for (NSDictionary *paymentInfo in [body[@"paymentMethods"] asArray]) {
  222. BTJSON *paymentInfoJSON = [[BTJSON alloc] initWithValue:paymentInfo];
  223. BTPaymentMethodNonce *paymentMethodNonce = [[BTPaymentMethodNonceParser sharedParser] parseJSON:paymentInfoJSON withParsingBlockForType:[paymentInfoJSON[@"type"] asString]];
  224. if (paymentMethodNonce) {
  225. [paymentMethodNonces addObject:paymentMethodNonce];
  226. }
  227. }
  228. completion(paymentMethodNonces, nil);
  229. }
  230. }
  231. });
  232. }];
  233. }
  234. #pragma mark - Remote Configuration
  235. - (void)fetchOrReturnRemoteConfiguration:(void (^)(BTConfiguration *, NSError *))completionBlock {
  236. // Guarantee that multiple calls to this method will successfully obtain configuration exactly once.
  237. //
  238. // Rules:
  239. // - If cachedConfiguration is present, return it without a request
  240. // - If cachedConfiguration is not present, fetch it and cache the succesful response
  241. // - If fetching fails, return error and the next queued will try to fetch again
  242. //
  243. // Note: Configuration queue is SERIAL. This helps ensure that each request for configuration
  244. // is processed independently. Thus, the check for cached configuration and the fetch is an
  245. // atomic operation with respect to other calls to this method.
  246. //
  247. // Note: Uses dispatch_semaphore to block the configuration queue when the configuration fetch
  248. // request is waiting to return. In this context, it is OK to block, as the configuration
  249. // queue is a background queue to guarantee atomic access to the remote configuration resource.
  250. dispatch_async(self.configurationQueue, ^{
  251. __block NSError *fetchError;
  252. dispatch_semaphore_t semaphore = dispatch_semaphore_create(0);
  253. __block BTConfiguration *configuration;
  254. NSString *configPath = @"v1/configuration"; // Default for tokenizationKey
  255. if (self.clientToken) {
  256. configPath = [self.clientToken.configURL absoluteString];
  257. } else if (self.payPalIDToken) {
  258. configPath = [self.payPalIDToken.configURL absoluteString];
  259. }
  260. [self.configurationHTTP GET:configPath parameters:@{ @"configVersion": @"3" } completion:^(BTJSON * _Nullable body, NSHTTPURLResponse * _Nullable response, NSError * _Nullable error) {
  261. if (error) {
  262. fetchError = error;
  263. } else if (response.statusCode != 200) {
  264. NSError *configurationDomainError =
  265. [NSError errorWithDomain:BTAPIClientErrorDomain
  266. code:BTAPIClientErrorTypeConfigurationUnavailable
  267. userInfo:@{
  268. NSLocalizedFailureReasonErrorKey: @"Unable to fetch remote configuration from Braintree API at this time."
  269. }];
  270. fetchError = configurationDomainError;
  271. } else {
  272. configuration = [[BTConfiguration alloc] initWithJSON:body];
  273. if (!self.braintreeAPI) {
  274. NSURL *apiURL = [configuration.json[@"braintreeApi"][@"url"] asURL];
  275. NSString *accessToken = [configuration.json[@"braintreeApi"][@"accessToken"] asString];
  276. self.braintreeAPI = [[BTAPIHTTP alloc] initWithBaseURL:apiURL accessToken:accessToken];
  277. }
  278. if (!self.http) {
  279. NSURL *baseURL = [configuration.json[@"clientApiUrl"] asURL];
  280. if (self.clientToken) {
  281. self.http = [[BTHTTP alloc] initWithBaseURL:baseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
  282. } else if (self.tokenizationKey) {
  283. self.http = [[BTHTTP alloc] initWithBaseURL:baseURL tokenizationKey:self.tokenizationKey];
  284. } else if (self.payPalIDToken) {
  285. self.http = [[BTHTTP alloc] initWithBaseURL:baseURL authorizationFingerprint:self.payPalIDToken.token];
  286. }
  287. }
  288. if (!self.graphQL) {
  289. NSURL *graphQLBaseURL = [BTAPIClient graphQLURLForEnvironment:[configuration.json[@"environment"] asString]];
  290. if (self.clientToken) {
  291. self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL authorizationFingerprint:self.clientToken.authorizationFingerprint];
  292. } else if (self.tokenizationKey) {
  293. self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL tokenizationKey:self.tokenizationKey];
  294. } else if (self.payPalIDToken) {
  295. self.graphQL = [[BTGraphQLHTTP alloc] initWithBaseURL:graphQLBaseURL authorizationFingerprint:self.payPalIDToken.token];
  296. }
  297. }
  298. }
  299. // Important: Unlock semaphore in all cases
  300. dispatch_semaphore_signal(semaphore);
  301. }];
  302. dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
  303. dispatch_async(dispatch_get_main_queue(), ^{
  304. completionBlock(configuration, fetchError);
  305. });
  306. });
  307. }
  308. #pragma mark - Analytics
  309. /// By default, the `BTAnalyticsService` instance is static/shared so that only one queue of events exists.
  310. /// The "singleton" is managed here because the analytics service depends on `BTAPIClient`.
  311. - (BTAnalyticsService *)analyticsService {
  312. static BTAnalyticsService *analyticsService;
  313. static dispatch_once_t onceToken;
  314. dispatch_once(&onceToken, ^{
  315. analyticsService = [[BTAnalyticsService alloc] initWithAPIClient:self];
  316. analyticsService.flushThreshold = 5;
  317. });
  318. // The analytics service may be overridden by unit tests. In that case, return the ivar and not the singleton
  319. if (_analyticsService) return _analyticsService;
  320. return analyticsService;
  321. }
  322. - (void)sendAnalyticsEvent:(NSString *)eventKind {
  323. [self.analyticsService sendAnalyticsEvent:eventKind completion:nil];
  324. }
  325. - (void)queueAnalyticsEvent:(NSString *)eventKind {
  326. [self.analyticsService sendAnalyticsEvent:eventKind];
  327. }
  328. - (NSDictionary *)metaParameters {
  329. NSMutableDictionary *metaParameters = [NSMutableDictionary dictionaryWithDictionary:self.metadata.parameters];
  330. [metaParameters addEntriesFromDictionary:[BTAnalyticsMetadata metadata]];
  331. return [metaParameters copy];
  332. }
  333. - (NSDictionary *)graphQLMetadata {
  334. return self.metadata.parameters;
  335. }
  336. - (NSDictionary *)metaParametersWithParameters:(NSDictionary *)parameters forHTTPType:(BTAPIClientHTTPType)httpType {
  337. if (httpType == BTAPIClientHTTPTypeBraintreeAPI) {
  338. return parameters;
  339. }
  340. NSMutableDictionary *mutableParameters = [NSMutableDictionary dictionaryWithDictionary:parameters];
  341. if (httpType == BTAPIClientHTTPTypeGraphQLAPI) {
  342. mutableParameters[@"clientSdkMetadata"] = [self graphQLMetadata];
  343. } else if (httpType == BTAPIClientHTTPTypeGateway) {
  344. mutableParameters[@"_meta"] = [self metaParameters];
  345. }
  346. return [mutableParameters copy];
  347. }
  348. #pragma mark - HTTP Operations
  349. - (void)GET:(NSString *)endpoint parameters:(NSDictionary *)parameters completion:(void(^)(BTJSON *body, NSHTTPURLResponse *response, NSError *error))completionBlock {
  350. [self GET:endpoint parameters:parameters httpType:BTAPIClientHTTPTypeGateway completion:completionBlock];
  351. }
  352. - (void)POST:(NSString *)endpoint parameters:(NSDictionary *)parameters completion:(void(^)(BTJSON *body, NSHTTPURLResponse *response, NSError *error))completionBlock {
  353. [self POST:endpoint parameters:parameters httpType:BTAPIClientHTTPTypeGateway completion:completionBlock];
  354. }
  355. - (void)GET:(NSString *)endpoint parameters:(NSDictionary *)parameters httpType:(BTAPIClientHTTPType)httpType completion:(void(^)(BTJSON *body, NSHTTPURLResponse *response, NSError *error))completionBlock {
  356. [self fetchOrReturnRemoteConfiguration:^(__unused BTConfiguration * _Nullable configuration, __unused NSError * _Nullable error) {
  357. if (error != nil) {
  358. completionBlock(nil, nil, error);
  359. return;
  360. }
  361. [[self httpForType:httpType] GET:endpoint parameters:parameters completion:completionBlock];
  362. }];
  363. }
  364. - (void)POST:(NSString *)endpoint parameters:(NSDictionary *)parameters httpType:(BTAPIClientHTTPType)httpType completion:(void(^)(BTJSON *body, NSHTTPURLResponse *response, NSError *error))completionBlock {
  365. [self fetchOrReturnRemoteConfiguration:^(__unused BTConfiguration * _Nullable configuration, __unused NSError * _Nullable error) {
  366. if (error != nil) {
  367. completionBlock(nil, nil, error);
  368. return;
  369. }
  370. NSDictionary *postParameters = [self metaParametersWithParameters:parameters forHTTPType:httpType];
  371. [[self httpForType:httpType] POST:endpoint parameters:postParameters completion:completionBlock];
  372. }];
  373. }
  374. - (BTHTTP *)httpForType:(BTAPIClientHTTPType)httpType {
  375. if (httpType == BTAPIClientHTTPTypeBraintreeAPI) {
  376. return self.braintreeAPI;
  377. } else if (httpType == BTAPIClientHTTPTypeGraphQLAPI) {
  378. return self.graphQL;
  379. }
  380. return self.http;
  381. }
  382. - (instancetype)init NS_UNAVAILABLE
  383. {
  384. return nil;
  385. }
  386. - (void)dealloc
  387. {
  388. if (self.http && self.http.session) {
  389. [self.http.session finishTasksAndInvalidate];
  390. }
  391. if (self.braintreeAPI && self.braintreeAPI.session) {
  392. [self.braintreeAPI.session finishTasksAndInvalidate];
  393. }
  394. if (self.graphQL && self.graphQL.session) {
  395. [self.graphQL.session finishTasksAndInvalidate];
  396. }
  397. }
  398. @end