Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Cartfile.private
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
github "AliSoftware/OHHTTPStubs"
1 change: 1 addition & 0 deletions Cartfile.resolved
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
github "AliSoftware/OHHTTPStubs" "8.0.0"
github "ReactiveX/RxSwift" "5.0.1"
32 changes: 32 additions & 0 deletions ReactiveAPI.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,19 @@
D1A36F1522A2CD69001D9ED5 /* SessionMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A36F1422A2CD69001D9ED5 /* SessionMock.swift */; };
D1AF9B09237FF6C2000513F9 /* URLRequestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AF9B07237FF6C2000513F9 /* URLRequestTests.swift */; };
D1AF9B0A237FF6C2000513F9 /* URLComponentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1AF9B08237FF6C2000513F9 /* URLComponentsTests.swift */; };
D1C4CA5E23D757D100B6F0CF /* MockAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C4CA5D23D757D100B6F0CF /* MockAPI.swift */; };
D1C4CA6023D7585100B6F0CF /* JSONHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1C4CA5F23D7585100B6F0CF /* JSONHelper.swift */; };
D1CD8B6922ABF1AA00324223 /* CacheMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CD8B6822ABF1AA00324223 /* CacheMock.swift */; };
D1CD8B6B22ABF99100324223 /* MaxAgeCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CD8B6A22ABF99100324223 /* MaxAgeCacheTests.swift */; };
D1CD8B6E22AC064900324223 /* ReactiveAPIErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1CD8B6D22AC064900324223 /* ReactiveAPIErrorTests.swift */; };
D1E3B8D423D1E4E300A94844 /* OHHTTPStubs.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D1E3B8D323D1E4E300A94844 /* OHHTTPStubs.framework */; };
D1E3B8D523D1E4EF00A94844 /* OHHTTPStubs.framework in Copy Files Carthage */ = {isa = PBXBuildFile; fileRef = D1E3B8D323D1E4E300A94844 /* OHHTTPStubs.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D811C14623D6EC4C005325BF /* URLRequest+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D811C14523D6EC4B005325BF /* URLRequest+.swift */; };
D811C14823D714EB005325BF /* RxTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D811C14723D714EB005325BF /* RxTest.framework */; };
D811C14923D714EB005325BF /* RxTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D811C14723D714EB005325BF /* RxTest.framework */; };
D811C14A23D7152A005325BF /* RxTest.framework in Copy Files Carthage */ = {isa = PBXBuildFile; fileRef = D811C14723D714EB005325BF /* RxTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D811C14B23D71537005325BF /* RxTest.framework in CopyFiles */ = {isa = PBXBuildFile; fileRef = D811C14723D714EB005325BF /* RxTest.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
D811C14D23D736DB005325BF /* Date+.swift in Sources */ = {isa = PBXBuildFile; fileRef = D811C14C23D736DB005325BF /* Date+.swift */; };
D820B595237FFA1E00C7B0D3 /* ReactiveAPIExt.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D820B58C237FFA1E00C7B0D3 /* ReactiveAPIExt.framework */; };
D820B5A3237FFA6000C7B0D3 /* LoadingResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E88A74235F0ECD0027797A /* LoadingResult.swift */; };
D820B5A4237FFA6000C7B0D3 /* ReactiveFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E88A72235F0D050027797A /* ReactiveFetcher.swift */; };
Expand Down Expand Up @@ -89,6 +99,8 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
D811C14A23D7152A005325BF /* RxTest.framework in Copy Files Carthage */,
D1E3B8D523D1E4EF00A94844 /* OHHTTPStubs.framework in Copy Files Carthage */,
D184126F22ABA72B00609847 /* RxRelay.framework in Copy Files Carthage */,
D184126E22ABA72000609847 /* RxBlocking.framework in Copy Files Carthage */,
D1942862228EC54A0071D00C /* RxCocoa.framework in Copy Files Carthage */,
Expand All @@ -103,6 +115,7 @@
dstPath = "";
dstSubfolderSpec = 10;
files = (
D811C14B23D71537005325BF /* RxTest.framework in CopyFiles */,
D820B5B1237FFB5000C7B0D3 /* ReactiveAPI.framework in CopyFiles */,
D820B5AD237FFB4900C7B0D3 /* RxBlocking.framework in CopyFiles */,
D820B5AE237FFB4900C7B0D3 /* RxCocoa.framework in CopyFiles */,
Expand Down Expand Up @@ -150,10 +163,16 @@
D1AF9B07237FF6C2000513F9 /* URLRequestTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLRequestTests.swift; sourceTree = "<group>"; };
D1AF9B08237FF6C2000513F9 /* URLComponentsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLComponentsTests.swift; sourceTree = "<group>"; };
D1BA2C2B237AE38C009CCDC2 /* ReactiveFetcherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactiveFetcherTests.swift; sourceTree = "<group>"; };
D1C4CA5D23D757D100B6F0CF /* MockAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAPI.swift; sourceTree = "<group>"; };
D1C4CA5F23D7585100B6F0CF /* JSONHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONHelper.swift; sourceTree = "<group>"; };
D1C7CC8E2374B87B007EDC5D /* LoadingResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingResultTests.swift; sourceTree = "<group>"; };
D1CD8B6822ABF1AA00324223 /* CacheMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheMock.swift; sourceTree = "<group>"; };
D1CD8B6A22ABF99100324223 /* MaxAgeCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaxAgeCacheTests.swift; sourceTree = "<group>"; };
D1CD8B6D22AC064900324223 /* ReactiveAPIErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactiveAPIErrorTests.swift; sourceTree = "<group>"; };
D1E3B8D323D1E4E300A94844 /* OHHTTPStubs.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = OHHTTPStubs.framework; path = Carthage/Build/iOS/OHHTTPStubs.framework; sourceTree = "<group>"; };
D811C14523D6EC4B005325BF /* URLRequest+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLRequest+.swift"; sourceTree = "<group>"; };
D811C14723D714EB005325BF /* RxTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = RxTest.framework; path = Carthage/Build/iOS/RxTest.framework; sourceTree = "<group>"; };
D811C14C23D736DB005325BF /* Date+.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+.swift"; sourceTree = "<group>"; };
D820B58C237FFA1E00C7B0D3 /* ReactiveAPIExt.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ReactiveAPIExt.framework; sourceTree = BUILT_PRODUCTS_DIR; };
D820B58F237FFA1E00C7B0D3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
D820B594237FFA1E00C7B0D3 /* ReactiveAPIExtTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReactiveAPIExtTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
Expand All @@ -167,11 +186,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D811C14823D714EB005325BF /* RxTest.framework in Frameworks */,
D164330022873CEB00C0F780 /* RxSwift.framework in Frameworks */,
D16432FA22873CB700C0F780 /* ReactiveAPI.framework in Frameworks */,
D1942861228EC5310071D00C /* RxCocoa.framework in Frameworks */,
D184126B22ABA67500609847 /* RxBlocking.framework in Frameworks */,
D184126D22ABA6FF00609847 /* RxRelay.framework in Frameworks */,
D1E3B8D423D1E4E300A94844 /* OHHTTPStubs.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -195,6 +216,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D811C14923D714EB005325BF /* RxTest.framework in Frameworks */,
D820B5AB237FFB3400C7B0D3 /* ReactiveAPI.framework in Frameworks */,
D820B595237FFA1E00C7B0D3 /* ReactiveAPIExt.framework in Frameworks */,
D820B5A7237FFB2B00C7B0D3 /* RxBlocking.framework in Frameworks */,
Expand Down Expand Up @@ -276,6 +298,8 @@
D195F1D322086D9600530339 /* Frameworks */ = {
isa = PBXGroup;
children = (
D811C14723D714EB005325BF /* RxTest.framework */,
D1E3B8D323D1E4E300A94844 /* OHHTTPStubs.framework */,
D184126A22ABA67500609847 /* RxBlocking.framework */,
D1942860228EC5310071D00C /* RxCocoa.framework */,
D184126C22ABA6FE00609847 /* RxRelay.framework */,
Expand Down Expand Up @@ -310,6 +334,8 @@
D1AF9B08237FF6C2000513F9 /* URLComponentsTests.swift */,
D1AF9B07237FF6C2000513F9 /* URLRequestTests.swift */,
D1996DE82369C69E00F25CA6 /* URLSessionRxTests.swift */,
D811C14523D6EC4B005325BF /* URLRequest+.swift */,
D811C14C23D736DB005325BF /* Date+.swift */,
);
path = Extensions;
sourceTree = "<group>";
Expand All @@ -320,6 +346,8 @@
D181276E22ABC712001CA667 /* AuthenticatorMock.swift */,
D1CD8B6822ABF1AA00324223 /* CacheMock.swift */,
D1996DEA2369DA7D00F25CA6 /* InterceptorMock.swift */,
D1C4CA5F23D7585100B6F0CF /* JSONHelper.swift */,
D1C4CA5D23D757D100B6F0CF /* MockAPI.swift */,
D181276822ABAC25001CA667 /* ModelMock.swift */,
D1A36F1422A2CD69001D9ED5 /* SessionMock.swift */,
);
Expand Down Expand Up @@ -534,6 +562,8 @@
D1CD8B6E22AC064900324223 /* ReactiveAPIErrorTests.swift in Sources */,
D16432F822873CB700C0F780 /* EncodableTests.swift in Sources */,
D1CD8B6B22ABF99100324223 /* MaxAgeCacheTests.swift in Sources */,
D811C14623D6EC4C005325BF /* URLRequest+.swift in Sources */,
D1C4CA5E23D757D100B6F0CF /* MockAPI.swift in Sources */,
D1996DE92369C69E00F25CA6 /* URLSessionRxTests.swift in Sources */,
D1AF9B0A237FF6C2000513F9 /* URLComponentsTests.swift in Sources */,
D181276922ABAC25001CA667 /* ModelMock.swift in Sources */,
Expand All @@ -542,9 +572,11 @@
D181276B22ABACBC001CA667 /* Resources.swift in Sources */,
D1996DEB2369DA7D00F25CA6 /* InterceptorMock.swift in Sources */,
D184127122ABA8C100609847 /* ReactiveAPIProtocolTests.swift in Sources */,
D811C14D23D736DB005325BF /* Date+.swift in Sources */,
D1A36F1322A2C181001D9ED5 /* JSONReactiveAPITests.swift in Sources */,
D1A36F1522A2CD69001D9ED5 /* SessionMock.swift in Sources */,
D181276F22ABC712001CA667 /* AuthenticatorMock.swift in Sources */,
D1C4CA6023D7585100B6F0CF /* JSONHelper.swift in Sources */,
D1AF9B09237FF6C2000513F9 /* URLRequestTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
10 changes: 10 additions & 0 deletions ReactiveAPITests/Extensions/Date+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import Foundation

extension Date {
var dateMillis: String {
let df = DateFormatter()
df.dateFormat = "y-MM-dd H:m:ss.SSSS"
return df.string(from: self)
}
}

15 changes: 15 additions & 0 deletions ReactiveAPITests/Extensions/URLRequest+.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

extension URLRequest {
func urlHasPrefix(_ prefix: String) -> Bool {
return self.url!.absoluteString.hasPrefix(prefix)
}

func urlHasSuffix(_ suffix: String) -> Bool {
return self.url!.absoluteString.hasSuffix(suffix)
}

func urlIsEquals(_ url: String) -> Bool {
return self.url!.absoluteString == url
}
}
4 changes: 2 additions & 2 deletions ReactiveAPITests/JSONReactiveAPITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ class JSONReactiveAPITests: XCTestCase {

func test_AbsoluteURL_AppendsEndpoint() {
let url = api.absoluteURL("path")
XCTAssertEqual(url.absoluteString, "https://baseurl.com/path")
XCTAssertEqual(url.absoluteString, "http://www.mock.com/path")
}

func test_AbsoluteURL_AppendsEmptyEndpoint() {
let url = api.absoluteURL("")
XCTAssertEqual(url.absoluteString, "https://baseurl.com/")
XCTAssertEqual(url.absoluteString, "http://www.mock.com/")
}
}
17 changes: 17 additions & 0 deletions ReactiveAPITests/Mocks/InterceptorMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,20 @@ struct InterceptorMock: ReactiveAPIRequestInterceptor {
return mutableRequest
}
}

public class TokenInterceptor : ReactiveAPIRequestInterceptor {

private let tokenValue: () -> String
private let headerName: String

public init(tokenValue: @escaping () -> String, headerName: String) {
self.tokenValue = tokenValue
self.headerName = headerName
}

public func intercept(_ request: URLRequest) -> URLRequest {
var mutableRequest = request
mutableRequest.addValue(tokenValue(), forHTTPHeaderField: headerName)
return mutableRequest
}
}
34 changes: 34 additions & 0 deletions ReactiveAPITests/Mocks/JSONHelper.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import Foundation
import OHHTTPStubs

public class JSONHelper {
public enum StubError: Error {
case inconsitency
}

public static func stubError() -> OHHTTPStubsResponse {
return OHHTTPStubsResponse(error: StubError.inconsitency)
}
static private let jsonContentType = ["Content-Type": "application/json"]

public static func jsonHttpResponse<T: Encodable>(value: T) throws -> OHHTTPStubsResponse {
let json = try JSONHelper.encode(value: value)
return OHHTTPStubsResponse(data: json,
statusCode: 200,
headers: jsonContentType)
}

public static func unauthorized401() -> OHHTTPStubsResponse {
return OHHTTPStubsResponse(data: Data(), statusCode: 401, headers: [:])
}

public static func encode<T: Encodable>(value: T) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .custom({ (date, encoder) in
var container = encoder.singleValueContainer()
let encodedDate = ISO8601DateFormatter().string(from: date)
try container.encode(encodedDate)
})
return try encoder.encode(value)
}
}
27 changes: 27 additions & 0 deletions ReactiveAPITests/Mocks/MockAPI.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation
import RxSwift
import ReactiveAPI

class MockAPI: ReactiveAPI {

public static let loginEndpoint = "login"
public static let renewEndpoint = "renew"
public static let authenticatedSingleActionEndpoint = "auth-action"
public static let authenticatedParallelActionEndpoint = "auth-parallel-action"

func login() -> Single<ModelMock> {
return request(url: absoluteURL(MockAPI.loginEndpoint))
}

func renewToken() -> Single<ModelMock> {
return request(url: absoluteURL(MockAPI.renewEndpoint))
}

func authenticatedSingleAction() -> Single<ModelMock> {
return request(url: absoluteURL(MockAPI.authenticatedSingleActionEndpoint))
}

func authenticatedParallelAction() -> Single<ModelMock> {
return request(url: absoluteURL(MockAPI.authenticatedParallelActionEndpoint))
}
}
2 changes: 1 addition & 1 deletion ReactiveAPITests/ReactiveAPIErrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ class ReactiveAPIErrorTests: XCTestCase {
XCTAssertEqual(typeMismatchReason, "root: Value not found.")

let dataCorruptedReason = ReactiveAPIError.decodingError(dataCorrupted, data: Resources.data).failureReason
XCTAssertEqual(dataCorruptedReason, dataCorrupted.failureReason)
XCTAssertEqual(dataCorruptedReason, "root: Value not found.")
}
}
109 changes: 109 additions & 0 deletions ReactiveAPITests/ReactiveAPITokenAuthenticatorTests.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import XCTest
import RxSwift
import OHHTTPStubs
@testable import ReactiveAPI

class ReactiveAPITokenAuthenticatorTests: XCTestCase {
override func setUp() {
super.setUp()
OHHTTPStubs.removeAllStubs()
OHHTTPStubs.onStubActivation { (request, _, _) in
debugPrint("Stubbed: \(String(describing: request.url))")
}
}

private let authenticator = ReactiveAPITokenAuthenticator(tokenHeaderName: "tokenHeaderName",
getCurrentToken: { "getCurrentToken" },
renewToken: { Single.just("renewToken") })
Expand Down Expand Up @@ -174,4 +183,104 @@ class ReactiveAPITokenAuthenticatorTests: XCTestCase {
default: XCTFail("This should throws an error!")
}
}

func test_multiple_parallel_failed_requests_should_trigger_a_single_token_refresh_and_be_retried_after_refresh() {
// Given
let queueAscheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue.init(label: "queueA"))
let queueBscheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue.init(label: "queueB"))
let queueCscheduler = ConcurrentDispatchQueueScheduler(queue: DispatchQueue.init(label: "queueC"))

var loginCounter = 0
var renewCounter = 0
var singleActionCounter = 0
var parallelActionCounter = 0
var callCounter = 0
var currentToken = ""

let tokenHeaderName = "tokenHeaderName"
let sut = MockAPI(session: URLSession.shared.rx, baseUrl: Resources.baseUrl)

sut.authenticator = ReactiveAPITokenAuthenticator(tokenHeaderName: tokenHeaderName,
getCurrentToken: { currentToken },
renewToken: {
sut.renewToken().map {
currentToken = $0.name
return $0.name
}})
sut.requestInterceptors += [
TokenInterceptor(tokenValue: { currentToken }, headerName: tokenHeaderName)
]

stub(condition: isHost(Resources.baseUrlHost)) { request -> OHHTTPStubsResponse in
callCounter += 1
print("\(callCounter) Request: \(request.url!.absoluteString)")

do {
if (request.urlHasSuffix(MockAPI.loginEndpoint)) {
loginCounter += 1
return try JSONHelper.jsonHttpResponse(value: ModelMock(name: "oldToken", id: 1))
}

if (request.urlHasSuffix(MockAPI.renewEndpoint)) {
renewCounter += 1
return try JSONHelper.jsonHttpResponse(value: ModelMock(name: "newToken", id: 2))
}

if (request.urlHasSuffix(MockAPI.authenticatedSingleActionEndpoint)) {
singleActionCounter += 1
return try JSONHelper.jsonHttpResponse(value: ModelMock(name: "singleAction", id: 3))
}

if (request.urlHasSuffix(MockAPI.authenticatedParallelActionEndpoint)) {
parallelActionCounter += 1
if (request.value(forHTTPHeaderField: tokenHeaderName) == "oldToken") {
return JSONHelper.unauthorized401()
}
return try JSONHelper.jsonHttpResponse(value: ModelMock(name: "parallelAction", id: 4))
}
} catch {
XCTFail("\(error)")
}

return JSONHelper.stubError()
}

do {
let loginResponse = try sut.login().toBlocking().single()
currentToken = loginResponse.name
let _ = try sut.authenticatedSingleAction().toBlocking().single()

let parallelCall1 = sut.authenticatedParallelAction()
.do(onSubscribed: {
print("\(Date().dateMillis) Parallel call 1 on \(Thread.current.description)")

}).subscribeOn(queueAscheduler)

let parallelCall2 = sut.authenticatedParallelAction()
.do(onSubscribed: {
print("\(Date().dateMillis) Parallel call 2 on \(Thread.current.description)")

}).subscribeOn(queueBscheduler)

let parallelCall3 = sut.authenticatedParallelAction()
.do(onSubscribed: {
print("\(Date().dateMillis) Parallel call 3 on \(Thread.current.description)")

}).subscribeOn(queueCscheduler)

// When
let events = try Single.zip(parallelCall1, parallelCall2, parallelCall3)
.toBlocking()
.single()

// Then
XCTAssertNotNil(events)
XCTAssertEqual(loginCounter, 1)
XCTAssertEqual(renewCounter, 1)
XCTAssertEqual(singleActionCounter, 1)
XCTAssertEqual(parallelActionCounter, 6)
} catch {
XCTFail("\(error)")
}
}
}
Loading