How do I implement custom network adapters with Alamofire?
Custom network adapters in Alamofire provide a powerful way to intercept, modify, and handle HTTP requests and responses at a low level. By implementing custom adapters, you can add advanced functionality like request transformation, response caching, authentication handling, and custom retry logic. This comprehensive guide will show you how to create and implement custom network adapters effectively.
Understanding Alamofire's Session Architecture
Alamofire uses a session-based architecture where Session
acts as the central coordinator for HTTP requests. Custom adapters work by implementing the RequestAdapter
and RequestRetrier
protocols, allowing you to customize request handling behavior.
Basic Request Adapter Implementation
Here's how to create a basic custom request adapter:
import Alamofire
import Foundation
class CustomRequestAdapter: RequestAdapter {
private let apiKey: String
private let baseURL: String
init(apiKey: String, baseURL: String) {
self.apiKey = apiKey
self.baseURL = baseURL
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var adaptedRequest = urlRequest
// Add custom headers
adaptedRequest.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
adaptedRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
adaptedRequest.setValue("iOS/14.0", forHTTPHeaderField: "User-Agent")
// Modify URL if needed
if let url = adaptedRequest.url,
let newURL = URL(string: baseURL + url.path) {
adaptedRequest.url = newURL
}
completion(.success(adaptedRequest))
}
}
Creating Advanced Request Adapters
Authentication and Token Management Adapter
For applications requiring dynamic authentication tokens, implement an adapter that handles token refresh:
class TokenAuthAdapter: RequestAdapter {
private var accessToken: String?
private let tokenRefreshService: TokenRefreshService
private let queue = DispatchQueue(label: "tokenAdapter", qos: .utility)
init(tokenRefreshService: TokenRefreshService) {
self.tokenRefreshService = tokenRefreshService
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
queue.async { [weak self] in
guard let self = self else {
completion(.failure(AdapterError.selfDeallocated))
return
}
// Check if token needs refresh
if self.shouldRefreshToken() {
self.tokenRefreshService.refreshToken { result in
switch result {
case .success(let newToken):
self.accessToken = newToken
completion(.success(self.addAuthHeader(to: urlRequest, token: newToken)))
case .failure(let error):
completion(.failure(error))
}
}
} else if let token = self.accessToken {
completion(.success(self.addAuthHeader(to: urlRequest, token: token)))
} else {
completion(.success(urlRequest))
}
}
}
private func addAuthHeader(to request: URLRequest, token: String) -> URLRequest {
var modifiedRequest = request
modifiedRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
return modifiedRequest
}
private func shouldRefreshToken() -> Bool {
// Implement token expiration logic
return accessToken == nil || tokenRefreshService.isTokenExpired()
}
}
enum AdapterError: Error {
case selfDeallocated
case tokenRefreshFailed
}
Request Signing Adapter
For APIs requiring request signing, create an adapter that adds cryptographic signatures:
import CryptoKit
class RequestSigningAdapter: RequestAdapter {
private let secretKey: String
private let keyId: String
init(secretKey: String, keyId: String) {
self.secretKey = secretKey
self.keyId = keyId
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var signedRequest = urlRequest
let timestamp = String(Int(Date().timeIntervalSince1970))
let nonce = UUID().uuidString
// Create signature string
let method = urlRequest.httpMethod ?? "GET"
let path = urlRequest.url?.path ?? ""
let query = urlRequest.url?.query ?? ""
let body = urlRequest.httpBody.map { String(data: $0, encoding: .utf8) } ?? ""
let signatureString = "\(method)\n\(path)\n\(query)\n\(body)\n\(timestamp)\n\(nonce)"
// Generate HMAC signature
if let signature = generateHMACSignature(for: signatureString) {
signedRequest.setValue(keyId, forHTTPHeaderField: "X-API-Key-ID")
signedRequest.setValue(timestamp, forHTTPHeaderField: "X-Timestamp")
signedRequest.setValue(nonce, forHTTPHeaderField: "X-Nonce")
signedRequest.setValue(signature, forHTTPHeaderField: "X-Signature")
}
completion(.success(signedRequest))
}
private func generateHMACSignature(for string: String) -> String? {
guard let keyData = secretKey.data(using: .utf8),
let messageData = string.data(using: .utf8) else {
return nil
}
let key = SymmetricKey(data: keyData)
let signature = HMAC<SHA256>.authenticationCode(for: messageData, using: key)
return Data(signature).base64EncodedString()
}
}
Implementing Request Retriers
Custom retriers handle failed requests and implement retry logic. Here's an advanced implementation:
class CustomRequestRetrier: RequestRetrier {
private let maxRetryCount: Int
private let retryDelay: TimeInterval
private var retryQueue = DispatchQueue(label: "retrier", qos: .utility)
init(maxRetryCount: Int = 3, retryDelay: TimeInterval = 1.0) {
self.maxRetryCount = maxRetryCount
self.retryDelay = retryDelay
}
func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
retryQueue.async { [weak self] in
guard let self = self else {
completion(.doNotRetry)
return
}
guard request.retryCount < self.maxRetryCount else {
completion(.doNotRetry)
return
}
// Determine if retry is appropriate based on error type
if self.shouldRetry(for: error, request: request) {
let delay = self.calculateRetryDelay(for: request.retryCount)
completion(.retryWithDelay(delay))
} else {
completion(.doNotRetry)
}
}
}
private func shouldRetry(for error: Error, request: Request) -> Bool {
// Check for specific error conditions
if let afError = error as? AFError {
switch afError {
case .sessionTaskFailed(let sessionError):
return isRetryableNetworkError(sessionError)
case .responseValidationFailed:
return false
default:
return false
}
}
// Check HTTP status codes
if let response = request.response,
let statusCode = response.response?.statusCode {
return isRetryableStatusCode(statusCode)
}
return false
}
private func isRetryableNetworkError(_ error: Error) -> Bool {
let nsError = error as NSError
// Network connectivity errors
let retryableCodes = [
NSURLErrorTimedOut,
NSURLErrorCannotConnectToHost,
NSURLErrorNetworkConnectionLost,
NSURLErrorDNSLookupFailed,
NSURLErrorNotConnectedToInternet
]
return retryableCodes.contains(nsError.code)
}
private func isRetryableStatusCode(_ statusCode: Int) -> Bool {
// Retry on server errors and rate limiting
return [429, 500, 502, 503, 504].contains(statusCode)
}
private func calculateRetryDelay(for retryCount: Int) -> TimeInterval {
// Exponential backoff with jitter
let baseDelay = retryDelay * pow(2.0, Double(retryCount))
let jitter = Double.random(in: 0...0.1) * baseDelay
return baseDelay + jitter
}
}
Configuring Session with Custom Adapters
Once you've created your custom adapters, configure your Alamofire session:
class NetworkManager {
private let session: Session
private let tokenService = TokenRefreshService()
init(apiKey: String, baseURL: String) {
// Create custom adapters
let requestAdapter = CustomRequestAdapter(apiKey: apiKey, baseURL: baseURL)
let tokenAdapter = TokenAuthAdapter(tokenRefreshService: tokenService)
let signingAdapter = RequestSigningAdapter(secretKey: "your-secret-key", keyId: "your-key-id")
let retrier = CustomRequestRetrier(maxRetryCount: 3, retryDelay: 1.0)
// Combine multiple adapters
let combinedAdapter = CombinedRequestAdapter(adapters: [
requestAdapter,
tokenAdapter,
signingAdapter
])
// Configure session
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 60
self.session = Session(
configuration: configuration,
requestAdapter: combinedAdapter,
requestRetrier: retrier
)
}
func makeRequest<T: Codable>(
_ url: String,
method: HTTPMethod = .get,
parameters: Parameters? = nil,
encoding: ParameterEncoding = URLEncoding.default
) -> DataRequest {
return session.request(
url,
method: method,
parameters: parameters,
encoding: encoding
)
.validate()
.responseDecodable(of: T.self) { response in
switch response.result {
case .success(let data):
print("Request succeeded: \(data)")
case .failure(let error):
print("Request failed: \(error)")
}
}
}
}
Combining Multiple Adapters
Create a combined adapter to use multiple adapters together:
class CombinedRequestAdapter: RequestAdapter {
private let adapters: [RequestAdapter]
init(adapters: [RequestAdapter]) {
self.adapters = adapters
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
processAdapters(urlRequest, adapters: adapters, session: session, completion: completion)
}
private func processAdapters(
_ urlRequest: URLRequest,
adapters: [RequestAdapter],
session: Session,
completion: @escaping (Result<URLRequest, Error>) -> Void
) {
guard let firstAdapter = adapters.first else {
completion(.success(urlRequest))
return
}
let remainingAdapters = Array(adapters.dropFirst())
firstAdapter.adapt(urlRequest, for: session) { [weak self] result in
switch result {
case .success(let adaptedRequest):
if remainingAdapters.isEmpty {
completion(.success(adaptedRequest))
} else {
self?.processAdapters(adaptedRequest, adapters: remainingAdapters, session: session, completion: completion)
}
case .failure(let error):
completion(.failure(error))
}
}
}
}
Advanced Use Cases
Caching Adapter
Implement request-level caching for improved performance:
class CachingRequestAdapter: RequestAdapter {
private let cache = URLCache(
memoryCapacity: 50 * 1024 * 1024, // 50MB
diskCapacity: 100 * 1024 * 1024, // 100MB
diskPath: "alamofire_cache"
)
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
var cachingRequest = urlRequest
// Set caching policy based on request type
if urlRequest.httpMethod == "GET" {
cachingRequest.cachePolicy = .returnCacheDataElseLoad
} else {
cachingRequest.cachePolicy = .reloadIgnoringLocalCacheData
}
completion(.success(cachingRequest))
}
}
Logging and Analytics Adapter
Add comprehensive logging for debugging and analytics:
class LoggingRequestAdapter: RequestAdapter {
private let logger: Logger
init(logger: Logger = Logger()) {
self.logger = logger
}
func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
// Log request details
logger.info("🚀 Outgoing Request:")
logger.info("URL: \(urlRequest.url?.absoluteString ?? "Unknown")")
logger.info("Method: \(urlRequest.httpMethod ?? "Unknown")")
logger.info("Headers: \(urlRequest.allHTTPHeaderFields ?? [:])")
if let body = urlRequest.httpBody,
let bodyString = String(data: body, encoding: .utf8) {
logger.info("Body: \(bodyString)")
}
// Add request tracking header
var trackedRequest = urlRequest
trackedRequest.setValue(UUID().uuidString, forHTTPHeaderField: "X-Request-ID")
completion(.success(trackedRequest))
}
}
Best Practices and Considerations
Error Handling
Always implement proper error handling in your adapters:
- Use appropriate error types for different failure scenarios
- Provide meaningful error messages for debugging
- Consider network conditions and handle them gracefully
Performance Optimization
- Keep adapter logic lightweight to avoid request delays
- Use appropriate dispatch queues for async operations
- Implement caching where beneficial
Security Considerations
When implementing custom network adapters, especially for web scraping applications, consider security aspects similar to handling browser sessions in Puppeteer where session management is crucial. Additionally, when dealing with dynamic content that requires JavaScript execution, you might need to integrate with tools that can handle such scenarios, much like handling AJAX requests using Puppeteer.
- Never log sensitive information like API keys or tokens
- Implement proper certificate validation
- Use secure protocols (HTTPS) for all communications
- Validate and sanitize all input parameters
Testing Custom Adapters
import XCTest
@testable import YourApp
class CustomRequestAdapterTests: XCTestCase {
var adapter: CustomRequestAdapter!
override func setUp() {
super.setUp()
adapter = CustomRequestAdapter(apiKey: "test-key", baseURL: "https://api.example.com")
}
func testAdapterAddsAuthorizationHeader() {
let expectation = XCTestExpectation(description: "Adapter completion")
let originalRequest = URLRequest(url: URL(string: "https://example.com/test")!)
adapter.adapt(originalRequest, for: Session.default) { result in
switch result {
case .success(let adaptedRequest):
XCTAssertEqual(
adaptedRequest.value(forHTTPHeaderField: "Authorization"),
"Bearer test-key"
)
expectation.fulfill()
case .failure:
XCTFail("Adapter should not fail for valid request")
}
}
wait(for: [expectation], timeout: 1.0)
}
}
Conclusion
Custom network adapters in Alamofire provide powerful capabilities for handling complex HTTP scenarios. By implementing request adapters and retriers, you can add authentication, request signing, caching, logging, and sophisticated retry logic to your networking layer. The key is to keep adapters focused, testable, and performant while handling errors gracefully.
Remember to thoroughly test your custom adapters and consider the security implications of any modifications you make to requests. With proper implementation, custom network adapters can significantly enhance your application's networking capabilities and provide better control over HTTP communications.