diff --git a/Example/Package.swift b/Example/Package.swift index c2a2cd9..9e925c4 100644 --- a/Example/Package.swift +++ b/Example/Package.swift @@ -21,7 +21,7 @@ var package = Package( .target( name: "PetStore", dependencies: [ - .product(name: "SwiftNetworking", package: "swift-networking"), + .product(name: "SwiftAPIClient", package: "swift-api-client"), ] ), ] diff --git a/Example/Sources/PetStore/CustomDecoder.swift b/Example/Sources/PetStore/CustomDecoder.swift index 459cf69..55921df 100644 --- a/Example/Sources/PetStore/CustomDecoder.swift +++ b/Example/Sources/PetStore/CustomDecoder.swift @@ -1,5 +1,5 @@ import Foundation -import SwiftNetworking +import SwiftAPIClient struct PetStoreDecoder: DataDecoder { diff --git a/Example/Sources/PetStore/ExampleOfCalls.swift b/Example/Sources/PetStore/ExampleOfCalls.swift index c2df32a..527a9a2 100644 --- a/Example/Sources/PetStore/ExampleOfCalls.swift +++ b/Example/Sources/PetStore/ExampleOfCalls.swift @@ -1,5 +1,5 @@ import Foundation -import SwiftNetworking +import SwiftAPIClient // MARK: - Usage example diff --git a/Example/Sources/PetStore/Models.swift b/Example/Sources/PetStore/Models.swift index d37627f..f3e2c47 100644 --- a/Example/Sources/PetStore/Models.swift +++ b/Example/Sources/PetStore/Models.swift @@ -40,3 +40,10 @@ public enum PetStatus: String, Codable { case pending case sold } + +public struct Tokens: Codable { + + public var accessToken: String + public var refreshToken: String + public var expiryDate: Date +} diff --git a/Example/Sources/PetStore/PetStore.swift b/Example/Sources/PetStore/PetStore.swift index 9d27e49..d76d7d9 100644 --- a/Example/Sources/PetStore/PetStore.swift +++ b/Example/Sources/PetStore/PetStore.swift @@ -1,5 +1,5 @@ import Foundation -import SwiftNetworking +import SwiftAPIClient public struct PetStore { @@ -11,10 +11,9 @@ public struct PetStore { client = APIClient(baseURL: baseURL.url) .fileIDLine(fileID: fileID, line: line) .bodyDecoder(PetStoreDecoder()) - .tokenRefresher { client, _ in - try await client.path("token").post() - } auth: { - .bearer(token: $0) + .tokenRefresher { refreshToken, client, _ in + let tokens: Tokens = try await client.path("token").post() + return (tokens.accessToken, tokens.refreshToken, tokens.expiryDate) } } } diff --git a/Example/Sources/PetStore/PetStoreBaseURL.swift b/Example/Sources/PetStore/PetStoreBaseURL.swift index b5e6393..dc0e173 100644 --- a/Example/Sources/PetStore/PetStoreBaseURL.swift +++ b/Example/Sources/PetStore/PetStoreBaseURL.swift @@ -1,5 +1,4 @@ import Foundation -import SwiftNetworking public extension PetStore { diff --git a/README.md b/README.md index 74d9c33..1c1f229 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ - [Encoding and Decoding](#encoding-and-decoding) - [ContentSerializer](#contentserializer) - [Auth](#auth) + - [Token refresher](#token-refresher) - [Mocking](#mocking) - [Logging](#logging) - [`APIClient.Configs`](#apiclientconfigs) @@ -42,8 +43,12 @@ let client = APIClient(url: baseURL) .bodyDecoder(.json(dateDecodingStrategy: .iso8601)) .bodyEncoder(.json(dateEncodingStrategy: .iso8601)) .errorDecoder(.decodable(APIError.self)) - .tokenRefresher { client, _ in - try await client("token").get() + .tokenRefresher { refreshToken, client, _ in + guard let refreshToken else { throw APIError.noRefreshToken } + let tokens: AuthTokens = try await client("auth", "token") + .body(["refresh_token": refreshToken]) + .post() + return (tokens.accessToken, tokens.refreshToken, tokens.expiresIn) } auth: { .bearer(token: $0) } @@ -151,6 +156,9 @@ The `.auth` configuration is an `AuthModifier` instance with several built-in `A - `.basic(username:password:)` for Basic authentication. - `.apiKey(key:field:)` for API Key authentication. +#### Token refresher +The `.tokenRefresher(...)` modifier can be used to specify a token refresher closure, which is called when a request returns a 401 status code. The refresher closure receives the cached refresh token, the client, and the response, and returns a new token, which is then used for the request. `.refreshToken` also sets the `.auth` configuration. + ### Mocking Built-in tools for mocking requests include: - `.mock(_:)` modifier to specify a mocked response for a request. @@ -244,7 +252,7 @@ import PackageDescription let package = Package( name: "SomeProject", dependencies: [ - .package(url: "https://github.com/dankinsoid/swift-api-client.git", from: "0.43.0") + .package(url: "https://github.com/dankinsoid/swift-api-client.git", from: "0.44.0") ], targets: [ .target( diff --git a/Sources/SwiftAPIClient/APIClientConfigs.swift b/Sources/SwiftAPIClient/APIClientConfigs.swift index 2de8c05..6805522 100644 --- a/Sources/SwiftAPIClient/APIClientConfigs.swift +++ b/Sources/SwiftAPIClient/APIClientConfigs.swift @@ -70,39 +70,37 @@ public func valueFor( public let _isPreview: Bool = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" #if !os(WASI) -public let _XCTIsTesting: Bool = { - ProcessInfo.processInfo.environment.keys.contains("XCTestBundlePath") - || ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath") - || ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier") - || (ProcessInfo.processInfo.arguments.first - .flatMap(URL.init(fileURLWithPath:)) - .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" } - ?? false) - || XCTCurrentTestCase != nil -}() +public let _XCTIsTesting: Bool = ProcessInfo.processInfo.environment.keys.contains("XCTestBundlePath") + || ProcessInfo.processInfo.environment.keys.contains("XCTestConfigurationFilePath") + || ProcessInfo.processInfo.environment.keys.contains("XCTestSessionIdentifier") + || (ProcessInfo.processInfo.arguments.first + .flatMap(URL.init(fileURLWithPath:)) + .map { $0.lastPathComponent == "xctest" || $0.pathExtension == "xctest" } + ?? false) + || XCTCurrentTestCase != nil #else public let _XCTIsTesting = false #endif #if canImport(ObjectiveC) private var XCTCurrentTestCase: AnyObject? { - guard - let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), - let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, - let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? - .takeUnretainedValue(), - let observers = shared.perform(Selector(("observers")))? - .takeUnretainedValue() as? [AnyObject], - let observer = - observers - .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), - let currentTestCase = observer.perform(Selector(("currentTestCase")))? - .takeUnretainedValue() - else { return nil } - return currentTestCase + guard + let XCTestObservationCenter = NSClassFromString("XCTestObservationCenter"), + let XCTestObservationCenter = XCTestObservationCenter as Any as? NSObjectProtocol, + let shared = XCTestObservationCenter.perform(Selector(("sharedTestObservationCenter")))? + .takeUnretainedValue(), + let observers = shared.perform(Selector(("observers")))? + .takeUnretainedValue() as? [AnyObject], + let observer = + observers + .first(where: { NSStringFromClass(type(of: $0)) == "XCTestMisuseObserver" }), + let currentTestCase = observer.perform(Selector(("currentTestCase")))? + .takeUnretainedValue() + else { return nil } + return currentTestCase } #else private var XCTCurrentTestCase: AnyObject? { - nil + nil } #endif diff --git a/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenCacheService.swift b/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenCacheService.swift index 190f4e4..295288e 100644 --- a/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenCacheService.swift +++ b/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenCacheService.swift @@ -1,132 +1,147 @@ import Foundation -/// A service for caching and retrieving tokens. -public protocol TokenCacheService { +/// A service for caching and retrieving secure data. +public protocol SecureCacheService { - func saveToken(_ token: String) throws - func getToken() -> String? - func clearToken() throws + subscript(key: SecureCacheServiceKey) -> String? { get nonmutating set } + func clear() throws } -public extension TokenCacheService where Self == MockTokenCacheService { +/// A key for a secure cache service. +public struct SecureCacheServiceKey: Hashable, ExpressibleByStringInterpolation { - /// A mock token cache service for testing. - static var mock: MockTokenCacheService { - MockTokenCacheService() + public var value: String + + public init(_ value: String) { + self.value = value } -} -public final class MockTokenCacheService: TokenCacheService { + public init(stringLiteral value: String) { + self.init(value) + } - private var token: String? + public init(stringInterpolation: String.StringInterpolation) { + self.init(String(stringInterpolation: stringInterpolation)) + } - public static let shared = MockTokenCacheService() + public static let accessToken: SecureCacheServiceKey = "accessToken" + public static let refreshToken: SecureCacheServiceKey = "refreshToken" + public static let expiryDate: SecureCacheServiceKey = "expiryDate" +} - public func saveToken(_ token: String) throws { - self.token = token - } +public extension SecureCacheService where Self == MockSecureCacheService { - public func getToken() -> String? { - token + /// A mock token cache service for testing. + static var mock: MockSecureCacheService { + .shared } +} + +public final class MockSecureCacheService: SecureCacheService { + + private var values: [SecureCacheServiceKey: String] = [:] - public func clearToken() throws { - token = nil + public static let shared = MockSecureCacheService() + + public subscript(key: SecureCacheServiceKey) -> String? { + get { values[key] } + set { values[key] = newValue } } + + public func clear() throws {} } #if canImport(Security) import Security -public extension TokenCacheService where Self == KeychainTokenCacheService { +public extension SecureCacheService where Self == KeychainCacheService { /// A Keychain token cache service with the default account and service. - static var keychain: KeychainTokenCacheService { - KeychainTokenCacheService() + static var keychain: KeychainCacheService { + .default } /// Creates a Keychain token cache service with the given account and service. /// - Parameters: - /// - account: The account name. /// - service: The service name. /// - /// `account` and `service` are used to differentiate between items stored in the Keychain. + /// `service` is used to differentiate between items stored in the Keychain. static func keychain( - account: String, - service: String = "TokenCacheService" - ) -> KeychainTokenCacheService { - KeychainTokenCacheService(account: account, service: service) + service: String? = nil + ) -> KeychainCacheService { + KeychainCacheService(service: service) } } -public struct KeychainTokenCacheService: TokenCacheService { +public struct KeychainCacheService: SecureCacheService { + + public let service: String? - public let account: String - public let service: String + /// The default Keychain token cache service. + public static var `default` = KeychainCacheService() - public init( - account: String = "apiclient.token", - service: String = "TokenCacheService" - ) { - self.account = account + public init(service: String? = nil) { self.service = service } - public func saveToken(_ token: String) throws { - // Create a query for saving the token - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecAttrService as String: service, - kSecValueData as String: token.data(using: .utf8)!, - ] - - // Try to delete the old token if it exists - SecItemDelete(query as CFDictionary) - - // Add the new token to the Keychain - let status = SecItemAdd(query as CFDictionary, nil) - - // Check the result - guard status == errSecSuccess else { - throw Errors.custom("Error saving the token to Keychain: \(status)") + public subscript(key: SecureCacheServiceKey) -> String? { + get { + // Create a query for retrieving the value + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.value, + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + if let service { + query[kSecAttrService as String] = service + } + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + // Check the result + guard status == errSecSuccess, let data = item as? Data, let token = String(data: data, encoding: .utf8) else { + return nil + } + + return token } - } - - public func getToken() -> String? { - // Create a query for retrieving the token - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecAttrService as String: service, - kSecReturnData as String: kCFBooleanTrue!, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - - // Check the result - guard status == errSecSuccess, let data = item as? Data, let token = String(data: data, encoding: .utf8) else { - return nil + nonmutating set { + // Create a query for saving the token + var query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key.value, + ] + + if let service { + query[kSecAttrService as String] = service + } + + // Try to delete the old value if it exists + SecItemDelete(query as CFDictionary) + + if let newValue { + query[kSecValueData as String] = newValue.data(using: .utf8) + // Add the new token to the Keychain + SecItemAdd(query as CFDictionary, nil) + // Check the result + // status == errSecSuccess + } } - - return token } - public func clearToken() throws { - // Create a query for deleting the token - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecAttrService as String: service, - ] + public func clear() throws { + var query: [String: Any] = [kSecClass as String: kSecClassGenericPassword] + + if let service { + query[kSecAttrService as String] = service + } - // Delete the token from the Keychain let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess else { - throw Errors.custom("Error clearing the token from Keychain: \(status)") + guard status == noErr || status == errSecSuccess else { + throw Errors.custom("Failed to clear the Keychain cache.") } } } diff --git a/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenRefresher.swift b/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenRefresher.swift index 5dd3cac..8e678c5 100644 --- a/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenRefresher.swift +++ b/Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenRefresher.swift @@ -1,35 +1,30 @@ import Foundation -public extension HTTPClientMiddleware where Self == TokenRefresherMiddleware { - - static func tokenRefresher( - cacheService: TokenCacheService = valueFor(live: .keychain, test: .mock), - expiredStatusCodes: Set = [.unauthorized], - refreshToken: @escaping (APIClient.Configs) async throws -> String, - auth: @escaping (String) -> AuthModifier - ) -> Self { - TokenRefresherMiddleware( - cacheService: cacheService, - expiredStatusCodes: expiredStatusCodes, - refreshToken: refreshToken, - auth: auth - ) - } -} - public extension APIClient { + /// Adds a `TokenRefresherMiddleware` to the client. + /// `TokenRefresherMiddleware` is used to refresh the token when it expires. + /// - Parameters: + /// - cacheService: The `SecureCacheService` to use for caching the token. Default to `.keychain`. + /// - expiredStatusCodes: The set of status codes that indicate an expired token. Default to `[401]`. + /// - request: The closure to use for requesting a new token and refresh token first time. Set to `nil` if you want to request and cache tokens manually. + /// - refresh: The closure to use for refreshing a new token with refresh token. + /// - auth: The closure that creates an `AuthModifier` for the new token. Default to `.bearer(token:)`. + /// + /// - Warning: Don't use this modifier with `.auth(_ modifier:)` as it will be override it. func tokenRefresher( - cacheService: TokenCacheService = valueFor(live: .keychain, test: .mock), + cacheService: SecureCacheService = valueFor(live: .keychain, test: .mock), expiredStatusCodes: Set = [.unauthorized], - refreshToken: @escaping (APIClient, APIClient.Configs) async throws -> String, - auth: @escaping (String) -> AuthModifier + request: ((APIClient, APIClient.Configs) async throws -> (accessToken: String, refreshToken: String?, expiryDate: Date?))? = nil, + refresh: @escaping (_ refreshToken: String?, APIClient, APIClient.Configs) async throws -> (accessToken: String, refreshToken: String?, expiryDate: Date?), + auth: @escaping (String) -> AuthModifier = AuthModifier.bearer ) -> Self { httpClientMiddleware( TokenRefresherMiddleware( cacheService: cacheService, expiredStatusCodes: expiredStatusCodes, - refreshToken: { try await refreshToken(self, $0) }, + request: request.map { request in { try await request(self, $0) } }, + refresh: { try await refresh($0, self, $1) }, auth: auth ) ) @@ -38,19 +33,22 @@ public extension APIClient { public struct TokenRefresherMiddleware: HTTPClientMiddleware { - private let tokenCacheService: TokenCacheService + private let tokenCacheService: SecureCacheService private let expiredStatusCodes: Set private let auth: (String) -> AuthModifier - private let refreshToken: (APIClient.Configs) async throws -> String + private let request: ((APIClient.Configs) async throws -> (String, String?, Date?))? + private let refresh: (String?, APIClient.Configs) async throws -> (String, String?, Date?) public init( - cacheService: TokenCacheService, + cacheService: SecureCacheService, expiredStatusCodes: Set = [.unauthorized], - refreshToken: @escaping (APIClient.Configs) async throws -> String, + request: ((APIClient.Configs) async throws -> (String, String?, Date?))?, + refresh: @escaping (String?, APIClient.Configs) async throws -> (String, String?, Date?), auth: @escaping (String) -> AuthModifier ) { tokenCacheService = cacheService - self.refreshToken = refreshToken + self.refresh = refresh + self.request = request self.auth = auth self.expiredStatusCodes = expiredStatusCodes } @@ -63,28 +61,70 @@ public struct TokenRefresherMiddleware: HTTPClientMiddleware { guard configs.isAuthEnabled else { return try await next(request, configs) } - var token: String - let currentToken = tokenCacheService.getToken() - if let currentToken { - token = currentToken + var accessToken: String + var refreshToken = tokenCacheService[.refreshToken] + if let currentToken = tokenCacheService[.accessToken] { + if + let expiryDateString = tokenCacheService[.expiryDate], + let currentExpiryDate = dateFormatter.date(from: expiryDateString), + currentExpiryDate > Date() + { + (accessToken, refreshToken, _) = try await refreshTokenAndCache(configs, refreshToken: refreshToken) + } else { + accessToken = currentToken + } } else { - token = try await refreshTokenAndCache(configs) + (accessToken, refreshToken, _) = try await requestTokenAndCache(configs) } var authorizedRequest = request - try auth(token).modifier(&authorizedRequest, configs) + try auth(accessToken).modifier(&authorizedRequest, configs) let result = try await next(authorizedRequest, configs) if expiredStatusCodes.contains(result.1.httpStatusCode) { - token = try await refreshTokenAndCache(configs) + (accessToken, refreshToken, _) = try await refreshTokenAndCache(configs, refreshToken: refreshToken) authorizedRequest = request - try auth(token).modifier(&authorizedRequest, configs) + try auth(accessToken).modifier(&authorizedRequest, configs) return try await next(authorizedRequest, configs) } return result } - private func refreshTokenAndCache(_ configs: APIClient.Configs) async throws -> String { - let token = try await refreshToken(configs) - try? tokenCacheService.saveToken(token) - return token + private func requestTokenAndCache( + _ configs: APIClient.Configs + ) async throws -> (String, String?, Date?) { + guard let request else { + throw Errors.custom("No cached token found.") + } + let (token, refreshToken, expiryDate) = try await request(configs) + tokenCacheService[.accessToken] = token + if let refreshToken { + tokenCacheService[.refreshToken] = refreshToken + } + if let expiryDate { + tokenCacheService[.expiryDate] = dateFormatter.string(from: expiryDate) + } + return (token, refreshToken, expiryDate) + } + + private func refreshTokenAndCache( + _ configs: APIClient.Configs, + refreshToken: String? + ) async throws -> (String, String?, Date?) { + let (token, refreshToken, expiryDate) = try await refresh(refreshToken, configs) + tokenCacheService[.accessToken] = token + if let refreshToken { + tokenCacheService[.refreshToken] = refreshToken + } + if let expiryDate { + tokenCacheService[.expiryDate] = dateFormatter.string(from: expiryDate) + } + return (token, refreshToken, expiryDate) } } + +private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ" + return formatter +}() diff --git a/Sources/SwiftAPIClient/Types/Errors.swift b/Sources/SwiftAPIClient/Types/Errors.swift index 90e8452..24f930a 100644 --- a/Sources/SwiftAPIClient/Types/Errors.swift +++ b/Sources/SwiftAPIClient/Types/Errors.swift @@ -1,6 +1,6 @@ import Foundation -enum Errors: LocalizedError { +enum Errors: LocalizedError, CustomStringConvertible { case unknown case invalidStatusCode(Int) @@ -14,6 +14,10 @@ enum Errors: LocalizedError { case custom(String) var errorDescription: String? { + description + } + + var description: String { switch self { case .unknown: return "Unknown error"