diff --git a/Package.swift b/Package.swift index 37bfe78e..60298e8d 100644 --- a/Package.swift +++ b/Package.swift @@ -63,6 +63,7 @@ let sources: [String] = ["Qonversion/Automations/Constants", "Qonversion/Qonversion/Utils", "Qonversion/Qonversion/Utils/QNDevice", "Qonversion/Qonversion/Utils/QNProperties", + "Qonversion/Qonversion/Utils/QNRateLimiter", "Qonversion/Qonversion/Utils/QNUserInfo", "Qonversion/Qonversion/Utils/QNUtils"] diff --git a/Podfile.lock b/Podfile.lock index ed0f3d54..5c00424a 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -96,4 +96,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: cd1911d751c59f56b7f0243af0b5eba4ce54bf26 -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.0 diff --git a/Qonversion.xcodeproj/project.pbxproj b/Qonversion.xcodeproj/project.pbxproj index ce890065..4049615d 100644 --- a/Qonversion.xcodeproj/project.pbxproj +++ b/Qonversion.xcodeproj/project.pbxproj @@ -39,6 +39,10 @@ 45FFA2EB24BEEA9A007EFB8F /* ProductCenterManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 45FFA2EA24BEEA9A007EFB8F /* ProductCenterManagerTests.m */; }; 45FFA2ED24BEF379007EFB8F /* full_init.json in Resources */ = {isa = PBXBuildFile; fileRef = 45FFA2EC24BEF379007EFB8F /* full_init.json */; }; 4CADC5602759181A004FDC10 /* AuthViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CADC55F2759181A004FDC10 /* AuthViewController.swift */; }; + 6A21BF4C2AB201A7005BDA7C /* QONRateLimiter.h in Headers */ = {isa = PBXBuildFile; fileRef = 6A21BF4B2AB201A7005BDA7C /* QONRateLimiter.h */; }; + 6A21BF4E2AB20483005BDA7C /* QONRateLimiter.m in Sources */ = {isa = PBXBuildFile; fileRef = 6A21BF4D2AB20483005BDA7C /* QONRateLimiter.m */; }; + 6A21BF532AB2059F005BDA7C /* QONRequest.h in Headers */ = {isa = PBXBuildFile; fileRef = 6A21BF522AB2059F005BDA7C /* QONRequest.h */; }; + 6A21BF552AB205AC005BDA7C /* QONRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = 6A21BF542AB205AC005BDA7C /* QONRequest.m */; }; 6A8A604329DAD363008EC7D8 /* Qonversion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 459DAB69243E329F0011ECF3 /* Qonversion.framework */; }; 6A8A604929DAD36E008EC7D8 /* QNAPIClientIntegrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6A8A603929D48B1A008EC7D8 /* QNAPIClientIntegrationTests.m */; }; 6A95DE9929E007A600350BD6 /* init_request_main_data.json in Resources */ = {isa = PBXBuildFile; fileRef = 6A95DE9829E007A600350BD6 /* init_request_main_data.json */; }; @@ -328,6 +332,10 @@ 45FFA2EA24BEEA9A007EFB8F /* ProductCenterManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ProductCenterManagerTests.m; sourceTree = ""; }; 45FFA2EC24BEF379007EFB8F /* full_init.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = full_init.json; sourceTree = ""; }; 4CADC55F2759181A004FDC10 /* AuthViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthViewController.swift; sourceTree = ""; }; + 6A21BF4B2AB201A7005BDA7C /* QONRateLimiter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QONRateLimiter.h; sourceTree = ""; }; + 6A21BF4D2AB20483005BDA7C /* QONRateLimiter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONRateLimiter.m; sourceTree = ""; }; + 6A21BF522AB2059F005BDA7C /* QONRequest.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = QONRequest.h; sourceTree = ""; }; + 6A21BF542AB205AC005BDA7C /* QONRequest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QONRequest.m; sourceTree = ""; }; 6A8A603929D48B1A008EC7D8 /* QNAPIClientIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = QNAPIClientIntegrationTests.m; sourceTree = ""; }; 6A8A603F29DAD363008EC7D8 /* IntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6A95DE9829E007A600350BD6 /* init_request_main_data.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = init_request_main_data.json; sourceTree = ""; }; @@ -764,6 +772,17 @@ path = workflows; sourceTree = ""; }; + 6A21BF4A2AB2018C005BDA7C /* QNRateLimiter */ = { + isa = PBXGroup; + children = ( + 6A21BF4B2AB201A7005BDA7C /* QONRateLimiter.h */, + 6A21BF4D2AB20483005BDA7C /* QONRateLimiter.m */, + 6A21BF522AB2059F005BDA7C /* QONRequest.h */, + 6A21BF542AB205AC005BDA7C /* QONRequest.m */, + ); + path = QNRateLimiter; + sourceTree = ""; + }; 6A8A604029DAD363008EC7D8 /* IntegrationTests */ = { isa = PBXGroup; children = ( @@ -1220,6 +1239,7 @@ 8957322526DD03A3009507A6 /* Utils */ = { isa = PBXGroup; children = ( + 6A21BF4A2AB2018C005BDA7C /* QNRateLimiter */, 8957322626DD03A3009507A6 /* QNUtils */, 8957322926DD03A3009507A6 /* QNDevice */, 8957322C26DD03A3009507A6 /* QNProperties */, @@ -1541,6 +1561,7 @@ 895732AD26DD03A3009507A6 /* QONAutomationsFlowCoordinator.h in Headers */, 8957328626DD03A3009507A6 /* QONAutomationsEvent.h in Headers */, 8957329826DD03A3009507A6 /* QONAutomationsEventType.h in Headers */, + 6A21BF4C2AB201A7005BDA7C /* QONRateLimiter.h in Headers */, 895732B426DD03A3009507A6 /* QONAutomationsService.h in Headers */, 895732CA26DD03A3009507A6 /* QNUtils.h in Headers */, 8957328126DD03A3009507A6 /* QONEntitlement.h in Headers */, @@ -1568,6 +1589,7 @@ 70D0E2BB291AA21C004E8DE8 /* QONLaunchMode.h in Headers */, 895732D026DD03A3009507A6 /* QNUserInfo.h in Headers */, 895732B126DD03A3009507A6 /* QONAutomationsFlowAssembly.h in Headers */, + 6A21BF532AB2059F005BDA7C /* QONRequest.h in Headers */, 8957329026DD03A3009507A6 /* QONEntitlementsUpdateListener.h in Headers */, 8957329326DD03A3009507A6 /* QONExperimentGroup.h in Headers */, 895732A126DD03A3009507A6 /* QONMacrosProcess.h in Headers */, @@ -1720,6 +1742,7 @@ TargetAttributes = { 454EF63B24E5CC580070581E = { CreatedOnToolsVersion = 11.5; + LastSwiftMigration = 1430; }; 459DAB68243E329F0011ECF3 = { CreatedOnToolsVersion = 11.3.1; @@ -1916,6 +1939,7 @@ 895732CE26DD03A3009507A6 /* QNProperties.m in Sources */, 8957327A26DD03A3009507A6 /* QNDevice+Advertising.m in Sources */, 895732C626DD03A3009507A6 /* QNAPIConstants.m in Sources */, + 6A21BF552AB205AC005BDA7C /* QONRequest.m in Sources */, 70D05A8F29C9FC1600EA5DDF /* QONRemoteConfigManager.m in Sources */, 895732C426DD03A3009507A6 /* QNRequestBuilder.m in Sources */, 8957329726DD03A3009507A6 /* QONStoreKitSugare.m in Sources */, @@ -1933,6 +1957,7 @@ 895732CB26DD03A3009507A6 /* QNUtils.m in Sources */, 70ADE7102951CC7200CB4D2E /* QONScreenPresentationConfiguration.m in Sources */, 707734F52A9F607700CFF742 /* QONRemoteConfigurationSource.m in Sources */, + 6A21BF4E2AB20483005BDA7C /* QONRateLimiter.m in Sources */, 895732A626DD03A3009507A6 /* QONAutomationsScreen.m in Sources */, 70ED951129FAAF31005F5D00 /* QONStoreKit2PurchaseModel.m in Sources */, 70EC019D29EEE94300E686E2 /* StoreKit2Service.swift in Sources */, @@ -2073,6 +2098,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sample/Sample.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = MTVL2X9L7N; @@ -2112,6 +2138,7 @@ ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Sample/Sample.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = MTVL2X9L7N; diff --git a/Sources/Qonversion/Public/QONErrors.h b/Sources/Qonversion/Public/QONErrors.h index 1c1b8d73..868dbb5e 100644 --- a/Sources/Qonversion/Public/QONErrors.h +++ b/Sources/Qonversion/Public/QONErrors.h @@ -116,7 +116,10 @@ typedef NS_ERROR_ENUM(QONErrorDomain, QONAPIError) { QONAPIErrorInvalidStoreCredentials = 13, // Receipt validation error - QONAPIErrorReceiptValidation = 14 + QONAPIErrorReceiptValidation = 14, + + // Rate limit exceeded + QONAPIErrorRateLimitExceeded = 15, } NS_SWIFT_NAME(Qonversion.APIError); @interface QONErrors: NSObject diff --git a/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.h b/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.h index e73b725f..3dad75b0 100644 --- a/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.h +++ b/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.h @@ -30,3 +30,5 @@ extern NSString *const kEventEndpoint; extern NSString *const kAccessDeniedError; extern NSString *const kInternalServerError; + +extern NSUInteger const kMaxSimilarRequestsPerSecond; \ No newline at end of file diff --git a/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.m b/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.m index 87d456cb..6c196216 100644 --- a/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.m +++ b/Sources/Qonversion/Qonversion/Constants/QNAPIConstants/QNAPIConstants.m @@ -36,3 +36,5 @@ NSString * const kAccessDeniedError = @"Access denied"; NSString * const kInternalServerError = @"Internal server error"; + +NSUInteger const kMaxSimilarRequestsPerSecond = 5; diff --git a/Sources/Qonversion/Qonversion/Services/QNAPIClient/QNAPIClient.m b/Sources/Qonversion/Qonversion/Services/QNAPIClient/QNAPIClient.m index ec7a5765..55230db0 100644 --- a/Sources/Qonversion/Qonversion/Services/QNAPIClient/QNAPIClient.m +++ b/Sources/Qonversion/Qonversion/Services/QNAPIClient/QNAPIClient.m @@ -9,6 +9,7 @@ #import "QNKeyedArchiver.h" #import "QONStoreKit2PurchaseModel.h" #import "QNDevice.h" +#import "QONRateLimiter.h" NSUInteger const kUnableToParseEmptyDataDefaultCode = 3840; @@ -17,6 +18,7 @@ @interface QNAPIClient() @property (nonatomic, strong) QNRequestSerializer *requestSerializer; @property (nonatomic, strong) QNRequestBuilder *requestBuilder; @property (nonatomic, strong) QNErrorsMapper *errorsMapper; +@property (nonatomic, strong) QONRateLimiter *rateLimiter; @property (nonatomic, copy) NSArray *retriableRequests; @property (nonatomic, copy) NSArray *criticalErrorCodes; @property (nonatomic, strong) NSError *criticalError; @@ -32,6 +34,7 @@ - (instancetype)init { _requestSerializer = [[QNRequestSerializer alloc] init]; _requestBuilder = [[QNRequestBuilder alloc] init]; _errorsMapper = [QNErrorsMapper new]; + _rateLimiter = [[QONRateLimiter alloc] initWithMaxRequestsPerSecond:kMaxSimilarRequestsPerSecond]; _apiKey = @""; _userID = @""; @@ -125,9 +128,20 @@ - (void)sendPushToken:(void (^)(BOOL success))completion { } - (void)launchRequest:(QNAPIClientDictCompletionHandler)completion { - NSDictionary *launchData = [self enrichParameters:[self.requestSerializer launchData]]; - NSURLRequest *request = [self.requestBuilder makeInitRequestWith:launchData]; - [self processDictRequest:request completion:completion]; + NSDictionary *launchData = [self.requestSerializer launchData]; + + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeInit + params:launchData + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSDictionary *enrichedData = [self enrichParameters:launchData]; + NSURLRequest *request = [self.requestBuilder makeInitRequestWith:enrichedData]; + [self processDictRequest:request completion:completion]; + }]; } - (NSURLRequest *)handlePurchase:(QONStoreKit2PurchaseModel *)purchaseInfo @@ -138,7 +152,16 @@ - (NSURLRequest *)handlePurchase:(QONStoreKit2PurchaseModel *)purchaseInfo NSURLRequest *request = [self.requestBuilder makePurchaseRequestWith:resultData]; - [self processDictRequest:request completion:completion]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypePurchase + params:body + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + [self processDictRequest:request completion:completion]; + }]; return [request copy]; } @@ -154,11 +177,20 @@ - (NSURLRequest *)purchaseRequestWith:(SKProduct *)product - (NSURLRequest *)purchaseRequestWith:(NSDictionary *)body completion:(QNAPIClientDictCompletionHandler)completion { NSDictionary *resultData = [self enrichParameters:body]; - + NSURLRequest *request = [self.requestBuilder makePurchaseRequestWith:resultData]; - - [self processDictRequest:request completion:completion]; - + + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypePurchase + params:body + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + [self processDictRequest:request completion:completion]; + }]; + return [request copy]; } @@ -171,10 +203,19 @@ - (void)checkTrialIntroEligibilityParamsForProducts:(NSArray *)pro - (void)checkTrialIntroEligibilityParamsForData:(NSDictionary *)data completion:(QNAPIClientDictCompletionHandler)completion { - NSDictionary *resultBody = [self enrichParameters:data]; - NSURLRequest *request = [self.requestBuilder makeIntroTrialEligibilityRequestWithData:resultBody]; - - return [self processDictRequest:request completion:completion]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeEligibilityForProducts + params:data + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSDictionary *resultBody = [self enrichParameters:data]; + NSURLRequest *request = [self.requestBuilder makeIntroTrialEligibilityRequestWithData:resultBody]; + + return [self processDictRequest:request completion:completion]; + }]; } - (void)sendProperties:(NSDictionary *)properties completion:(QNAPIClientDictCompletionHandler)completion { @@ -187,14 +228,23 @@ - (void)sendProperties:(NSDictionary *)properties completion:(QNAPIClientDictCom } NSURLRequest *request = [self.requestBuilder makeSendPropertiesRequestForUserId:_userID parameters:propertiesForApi]; - + [self processDictRequest:request completion:completion]; } - (void)getProperties:(QNAPIClientArrayCompletionHandler)completion { - NSURLRequest *request = [self.requestBuilder makeGetPropertiesRequestForUserId:_userID]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeGetProperties + hash:[self.userID hash] + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } - [self processArrayRequest:request completion:completion]; + NSURLRequest *request = [self.requestBuilder makeGetPropertiesRequestForUserId:self.userID]; + + [self processArrayRequest:request completion:completion]; + }]; } - (void)userActionPointsWithCompletion:(QNAPIClientDictCompletionHandler)completion { @@ -210,15 +260,34 @@ - (void)automationWithID:(NSString *)automationID completion:(QNAPIClientDictCom } - (void)userInfoRequestWithID:(NSString *)userID completion:(QNAPIClientDictCompletionHandler)completion { - NSURLRequest *request = [self.requestBuilder makeUserInfoRequestWithID:userID apiKey:[self obtainApiKey]]; - return [self processDictRequest:request completion:completion]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeUserInfo + hash:[userID hash] + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSURLRequest *request = [self.requestBuilder makeUserInfoRequestWithID:userID apiKey:[self obtainApiKey]]; + return [self processDictRequest:request completion:completion]; + }]; } - (void)createIdentityForUserID:(NSString *)userID anonUserID:(NSString *)anonUserID completion:(QNAPIClientDictCompletionHandler)completion { NSDictionary *parameters = @{@"anon_id": anonUserID, @"identity_id": userID}; - NSURLRequest *request = [self.requestBuilder makeCreateIdentityRequestWith:parameters]; - - return [self processDictRequest:request completion:completion]; + + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeIdentify + params:parameters + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSURLRequest *request = [self.requestBuilder makeCreateIdentityRequestWith:parameters]; + + return [self processDictRequest:request completion:completion]; + }]; } - (void)trackScreenShownWithID:(NSString *)automationID { @@ -237,9 +306,19 @@ - (void)attributionRequest:(QONAttributionProvider)provider data:(NSDictionary *)data completion:(QNAPIClientDictCompletionHandler)completion { NSDictionary *body = [self.requestSerializer attributionDataWithDict:data fromProvider:provider]; - NSDictionary *resultData = [self enrichParameters:body]; - NSURLRequest *request = [[self requestBuilder] makeAttributionRequestWith:resultData]; - [self processDictRequest:request completion:completion]; + + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeAttribution + params:body + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSDictionary *resultData = [self enrichParameters:body]; + NSURLRequest *request = [[self requestBuilder] makeAttributionRequestWith:resultData]; + [self processDictRequest:request completion:completion]; + }]; } - (void)processStoredRequests { @@ -290,21 +369,48 @@ - (void)sendCrashReport:(NSDictionary *)data completion:(QNAPIClientEmptyComplet } - (void)loadRemoteConfig:(QNAPIClientDictCompletionHandler)completion { - NSURLRequest *request = [self.requestBuilder remoteConfigRequestForUserId:self.userID]; - - return [self processDictRequest:request completion:completion]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeRemoteConfig + hash:[self.userID hash] + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(nil, rateLimitError); + return; + } + + NSURLRequest *request = [self.requestBuilder remoteConfigRequestForUserId:self.userID]; + + return [self processDictRequest:request completion:completion]; + }]; } - (void)attachUserToExperiment:(NSString *)experimentId groupId:(NSString *)groupId completion:(QNAPIClientEmptyCompletionHandler)completion { - NSURLRequest *request = [self.requestBuilder makeAttachUserToExperimentRequest:experimentId groupId:groupId userID:self.userID]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeAttachUserToExperiment + hash:[[NSString stringWithFormat:@"%@%@%@", self.userID, experimentId, groupId] hash] + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(rateLimitError); + return; + } - [self processRequestWithoutResponse:request completion:completion]; + NSURLRequest *request = [self.requestBuilder makeAttachUserToExperimentRequest:experimentId groupId:groupId userID:self.userID]; + + [self processRequestWithoutResponse:request completion:completion]; + }]; } - (void)detachUserFromExperiment:(NSString *)experimentId completion:(QNAPIClientEmptyCompletionHandler)completion { - NSURLRequest *request = [self.requestBuilder makeDetachUserToExperimentRequest:experimentId userID:self.userID]; + [self.rateLimiter validateRateLimit:QONRateLimitedRequestTypeDetachUserFromExperiment + hash:[[NSString stringWithFormat:@"%@%@", self.userID, experimentId] hash] + completion:^(NSError *rateLimitError) { + if (rateLimitError != nil) { + completion(rateLimitError); + return; + } - [self processRequestWithoutResponse:request completion:completion]; + NSURLRequest *request = [self.requestBuilder makeDetachUserToExperimentRequest:experimentId userID:self.userID]; + + [self processRequestWithoutResponse:request completion:completion]; + }]; } // MARK: - Private diff --git a/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.h b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.h new file mode 100644 index 00000000..dbc92a20 --- /dev/null +++ b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.h @@ -0,0 +1,47 @@ +// +// QONRateLimiter.h +// Qonversion +// +// Created by Kamo Spertsyan on 13.09.2023. +// Copyright © 2023 Qonversion Inc. All rights reserved. +// + +#ifndef QNRateLimiter_h +#define QNRateLimiter_h + +#import + +typedef void (^QONRateLimiterCompletionHandler)(NSError * _Nullable rateLimitError); + +typedef NS_ENUM(NSInteger, QONRateLimitedRequestType) { + QONRateLimitedRequestTypeInit = 0, + QONRateLimitedRequestTypeRemoteConfig = 1, + QONRateLimitedRequestTypeAttachUserToExperiment = 2, + QONRateLimitedRequestTypeDetachUserFromExperiment = 3, + QONRateLimitedRequestTypePurchase = 4, + QONRateLimitedRequestTypeUserInfo = 5, + QONRateLimitedRequestTypeAttribution = 6, + QONRateLimitedRequestTypeGetProperties = 7, + QONRateLimitedRequestTypeEligibilityForProducts = 8, + QONRateLimitedRequestTypeIdentify = 9 +}; + +@interface QONRateLimiter : NSObject + +- (instancetype _Nullable)initWithMaxRequestsPerSecond:(NSUInteger)maxRequestsPerSecond; + +- (void)validateRateLimit:(QONRateLimitedRequestType)requestType + params:(NSDictionary * _Nonnull)params + completion:(QONRateLimiterCompletionHandler _Nonnull)completion; + +- (void)validateRateLimit:(QONRateLimitedRequestType)requestType + hash:(NSUInteger)hash + completion:(QONRateLimiterCompletionHandler _Nonnull)completion; + +- (void)saveRequest:(QONRateLimitedRequestType)requestType hash:(NSUInteger)hash; + +- (BOOL)isRateLimitExceeded:(QONRateLimitedRequestType)requestType hash:(NSUInteger)hash; + +@end + +#endif /* QNRateLimiter_h */ diff --git a/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.m b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.m new file mode 100644 index 00000000..e2c529c0 --- /dev/null +++ b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRateLimiter.m @@ -0,0 +1,148 @@ +// +// QONRateLimiter.m +// Qonversion +// +// Created by Kamo Spertsyan on 13.09.2023. +// Copyright © 2023 Qonversion Inc. All rights reserved. +// + +#import +#import "QONRateLimiter.h" +#import "QONRequest.h" +#import "QONErrors.h" + +@interface QONRateLimiter() + +@property (nonatomic, assign) NSUInteger maxRequestsPerSecond; +@property (nonatomic, strong) NSMutableDictionary *> *requests; + +@end + +@implementation QONRateLimiter + +- (instancetype)initWithMaxRequestsPerSecond:(NSUInteger)maxRequestsPerSecond { + self = [super init]; + if (self) { + _maxRequestsPerSecond = maxRequestsPerSecond; + _requests = [NSMutableDictionary new]; + } + + return self; +} + +- (void)validateRateLimit:(QONRateLimitedRequestType)requestType + params:(NSDictionary *)params + completion:(QONRateLimiterCompletionHandler _Nonnull)completion { + NSUInteger hash = [self calculateHashForDictionary:params]; + [self validateRateLimit:requestType hash:hash completion:completion]; +} + +- (void)validateRateLimit:(QONRateLimitedRequestType)requestType + hash:(NSUInteger)hash + completion:(QONRateLimiterCompletionHandler)completion { + if (!completion) { + return; + } + + if ([self isRateLimitExceeded:requestType hash:hash]) { + completion([QONErrors errorWithCode:QONAPIErrorRateLimitExceeded]); + } else { + [self saveRequest:requestType hash:hash]; + completion(nil); + } +} + +- (void)saveRequest:(QONRateLimitedRequestType)requestType hash:(NSUInteger)hash { + @synchronized (self.requests) { + NSTimeInterval timestamp = [[NSDate date] timeIntervalSince1970]; + + if (!self.requests[@(requestType)]) { + self.requests[@(requestType)] = [NSMutableArray new]; + } + + QONRequest *request = [[QONRequest alloc] initWithTimestamp:timestamp andHash:hash]; + [self.requests[@(requestType)] addObject:request]; + }; +} + +- (BOOL)isRateLimitExceeded:(QONRateLimitedRequestType)requestType hash:(NSUInteger)hash { + @synchronized (self.requests) { + [self removeOutdatedRequests:requestType]; + + NSArray *requestsPerType = self.requests[@(requestType)]; + if (!requestsPerType) { + return NO; + } + + NSUInteger matchCount = 0; + for (NSUInteger i = 0; i < requestsPerType.count && matchCount < self.maxRequestsPerSecond; i++) { + QONRequest *request = requestsPerType[i]; + if (request.hashValue == hash) { + matchCount++; + } + } + + return matchCount >= self.maxRequestsPerSecond; + }; +} + +// MARK: Private + +- (void)removeOutdatedRequests:(QONRateLimitedRequestType)requestType { + NSArray *requestsPerType = self.requests[@(requestType)]; + if (!requestsPerType) { + return; + } + + NSTimeInterval timestamp = [[NSDate date] timeIntervalSince1970]; + NSMutableArray *filteredRequests = [NSMutableArray new]; + for (NSInteger i = requestsPerType.count - 1; i >= 0 && timestamp - requestsPerType[i].timestamp < 1 /* sec */; --i) { + [filteredRequests insertObject:requestsPerType[i] atIndex:0]; + } + + self.requests[@(requestType)] = filteredRequests; +} + +- (NSUInteger)calculateHashForDictionary:(NSDictionary *)dict { + NSUInteger prime = 31; + NSUInteger result = 1; + + for (NSString *key in dict) { + id value = dict[key]; + + NSUInteger keyHash = [key hash]; + NSUInteger valueHash = [self calculateHashForValue:value]; + + result = prime * result + keyHash; + result = prime * result + valueHash; + } + + return result; +} + +- (NSUInteger)calculateHashForArray:(NSArray *)array { + NSUInteger prime = 31; + NSUInteger result = 1; + + for (id value in array) { + NSUInteger valueHash = [self calculateHashForValue:value]; + result = prime * result + valueHash; + } + + return result; +} + +- (NSUInteger)calculateHashForValue:(id)value { + NSUInteger valueHash = 0; + if ([value isKindOfClass:[NSDictionary class]]) { + valueHash = [self calculateHashForDictionary:value]; + } else if ([value isKindOfClass:[NSArray class]]) { + valueHash = [self calculateHashForArray:value]; + } else { + valueHash = [value hash]; + } + + return valueHash; +} + +@end diff --git a/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.h b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.h new file mode 100644 index 00000000..ca4c808e --- /dev/null +++ b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.h @@ -0,0 +1,24 @@ +// +// QONRequest.h +// Qonversion +// +// Created by Kamo Spertsyan on 13.09.2023. +// Copyright © 2023 Qonversion Inc. All rights reserved. +// + +#ifndef QNRequest_h +#define QNRequest_h + +#import +#import "QONRateLimiter.h" + +@interface QONRequest : NSObject + +@property (nonatomic, assign, readonly) NSTimeInterval timestamp; +@property (nonatomic, assign, readonly) NSUInteger hashValue; + +- (instancetype)initWithTimestamp:(NSTimeInterval)timestamp andHash:(NSUInteger)hashValue; + +@end + +#endif /* QNRequest_h */ diff --git a/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.m b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.m new file mode 100644 index 00000000..0a5e8f5e --- /dev/null +++ b/Sources/Qonversion/Qonversion/Utils/QNRateLimiter/QONRequest.m @@ -0,0 +1,25 @@ +// +// QONRequest.m +// Qonversion +// +// Created by Kamo Spertsyan on 13.09.2023. +// Copyright © 2023 Qonversion Inc. All rights reserved. +// + +#import +#import "QONRequest.h" + +@implementation QONRequest + +- (instancetype)initWithTimestamp:(NSTimeInterval)timestamp andHash:(NSUInteger)hashValue { + self = [super init]; + + if (self) { + _timestamp = timestamp; + _hashValue = hashValue; + } + + return self; +} + +@end