Skip to content

Commit

Permalink
0.44.0
Browse files Browse the repository at this point in the history
  • Loading branch information
dankinsoid committed Mar 21, 2024
1 parent b578c1a commit b96e55e
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 161 deletions.
2 changes: 1 addition & 1 deletion Example/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var package = Package(
.target(
name: "PetStore",
dependencies: [
.product(name: "SwiftNetworking", package: "swift-networking"),
.product(name: "SwiftAPIClient", package: "swift-api-client"),
]
),
]
Expand Down
2 changes: 1 addition & 1 deletion Example/Sources/PetStore/CustomDecoder.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import SwiftNetworking
import SwiftAPIClient

struct PetStoreDecoder: DataDecoder {

Expand Down
2 changes: 1 addition & 1 deletion Example/Sources/PetStore/ExampleOfCalls.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import SwiftNetworking
import SwiftAPIClient

// MARK: - Usage example

Expand Down
7 changes: 7 additions & 0 deletions Example/Sources/PetStore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
9 changes: 4 additions & 5 deletions Example/Sources/PetStore/PetStore.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Foundation
import SwiftNetworking
import SwiftAPIClient

public struct PetStore {

Expand All @@ -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)
}
}
}
Expand Down
1 change: 0 additions & 1 deletion Example/Sources/PetStore/PetStoreBaseURL.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Foundation
import SwiftNetworking

public extension PetStore {

Expand Down
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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(
Expand Down
48 changes: 23 additions & 25 deletions Sources/SwiftAPIClient/APIClientConfigs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,39 +70,37 @@ public func valueFor<Value>(
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
185 changes: 100 additions & 85 deletions Sources/SwiftAPIClient/Modifiers/TokenRefresher/TokenCacheService.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
}
Expand Down
Loading

0 comments on commit b96e55e

Please sign in to comment.