Networking - toant-dev/toandev.github.io GitHub Wiki

Alamofire

Components

  • RxAlamorefire
  • JsonEnconder/JsonDecoder
  • Reachibility

RequestInterceptor for refresh token + retry

class OAuthHandler: RequestInterceptor {
    
    private typealias RefreshCompletion = (_ succeeded: Bool, _ accessToken: String?, _ refreshToken: String?) -> Void
    
    private let sessionManager: Session = {
        let configuration = URLSessionConfiguration.default
        configuration.headers = .default
        configuration.timeoutIntervalForRequest = 20
        configuration.timeoutIntervalForResource = 20
        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
        
        return Session(configuration: configuration)
    }()
    
    private let lock = NSLock()
    
    private var isRefreshing = false
    private var requestsToRetry: [(RetryResult) -> Void] = []
    
    private let noLoginRequriedUrls = [
        API.Endpoints.Authentication.signIn,
        API.Endpoints.Authentication.refreshToken
    ]
    
    init() {
        debugPrint("DI:Init OAuthHandler")
    }
    
    // MARK: - RequestAdapter
    
    func adapt(_ urlRequest: URLRequest, for session: Session, completion: @escaping (Result<URLRequest, Error>) -> Void) {
        var urlRequest = urlRequest
        let url:String = urlRequest.url?.absoluteString ?? ""
        debugPrint("HANDLE_REQUEST: Adapt request " + url)
        if let accessToken = UDStorage.shared.accessToken {
            if !noLoginRequriedUrls.contains(url) {
                if urlRequest.value(forHTTPHeaderField: "Authorization")?.isEmpty ?? true {
                    urlRequest.setValue("Bearer " + accessToken, forHTTPHeaderField: "Authorization")
                }
            }
        }
        completion(.success(urlRequest))
    }
    
    func isReachable() -> Bool {
        if let reachibility = try? Reachability() {
            return reachibility.connection != Reachability.Connection.unavailable
        }
        return false
    }
    
    // MARK: - RequestRetrier
    
    func retry(_ request: Request, for session: Session, dueTo error: Error, completion: @escaping (RetryResult) -> Void) {
        
        lock.lock() ; defer { lock.unlock() }
        if let response = request.task?.response as? HTTPURLResponse {
            debugPrint("HANDLE_REQUEST: Should request \(request.request?.url?.absoluteString ?? "") with status code: \(response.statusCode)")
        }
        
        if let response = request.task?.response as? HTTPURLResponse, response.statusCode == 401 && UDStorage.shared.refreshToken != nil {
            
            requestsToRetry.append(completion)
            
            if !isRefreshing {
                refreshTokens { [weak self] succeeded, accessToken, refreshToken in
                    guard let self = self else { return }
                    
                    self.lock.lock() ; defer { self.lock.unlock() }
                    
                    debugPrint("HANDLE_REQUEST: Refresh token - access token: \(accessToken ?? "nil")")
                    debugPrint("HANDLE_REQUEST: Refresh token - refresh token: \(refreshToken ?? "nil")")
                    
                    if let accessToken = accessToken, let refreshToken = refreshToken {
                        UDStorage.shared.accessToken = accessToken
                        UDStorage.shared.refreshToken = refreshToken
                        NotificationCenter.default.post(name: Notification.Name.API.TokenRefreshed, object: nil, userInfo: nil)
                        
                        self.requestsToRetry.forEach { $0(.retry) }
                        self.requestsToRetry.removeAll()
                    } else {
                        // Token already in black list, can't be refresh. Force go to login without logout
                        debugPrint("HANDLE_REQUEST: Refresh token fail")
                        UDStorage.shared.accessToken = nil
                        UDStorage.shared.refreshToken = nil
                        NotificationCenter.default.post(name: Notification.Name.API.TokenExpried, object: nil, userInfo: nil)
                        
                        self.requestsToRetry.forEach { $0(.doNotRetry) }
                        self.requestsToRetry.removeAll()
                    }
                }
            }
        } else {
            completion(.doNotRetryWithError(error))
        }
    }
    
    // MARK: - Private - Refresh Tokens
    
    private func refreshTokens(completion: @escaping RefreshCompletion) {
        guard !isRefreshing else {
            completion(false, nil, nil)
            return
        }
        
        guard let refreshToken = UDStorage.shared.refreshToken else {
            completion(false, nil, nil)
            return
        }
        
        debugPrint("HANDLE_REQUEST: Refresh token with token: \(refreshToken)")
        
        isRefreshing = true
        
        let urlString = API.Endpoints.Authentication.refreshToken
        
        let parameters: [String: Any] = [
            "refreshToken": refreshToken,
        ]
        
        sessionManager.request(urlString, method: .post, parameters: parameters, encoding: JSONEncoding.default)
            .responseJSON { [weak self] response in
                guard let self = self else { return }
                
                self.isRefreshing = false
                
                if response.response?.statusCode == 400 {
                    completion(false, nil, nil)
                    self.isRefreshing = false
                    return
                }
                
                guard let data = response.data,
                    let result = try? JSONDecoder().decode(RefreshTokenResponse.self, from: data),
                    let accessToken = result.accessToken, let refreshToken = result.refreshToken  else {
                    completion(false, nil, nil)
                    return
                }
                
                completion(true, accessToken, refreshToken)
        }
    }
}

struct RefreshTokenResponse: Decodable {
    let accessToken: String?
    let refreshToken: String?
    
    enum CodingKeys: String, CodingKey {
        case accessToken = "token"
        case refreshToken = "refresh_token"
    }
}

extension Notification.Name {
    
    public struct API {
        /// Posted when token expired
        public static let TokenExpried = Notification.Name(rawValue: "api.TokenExpried")
        /// Posted when token refreshed
        public static let TokenRefreshed = Notification.Name(rawValue: "api.TokenRefreshed")
    }
}

APIService

class APIService {
    
    let sessionManager: Session
    
    init(oAuthHandler: OAuthHandler? = nil) {
        debugPrint("DI:Init APIService with oAuthHandler = \(String(describing: oAuthHandler))")
        
        let configuration = URLSessionConfiguration.default
        configuration.headers = .default
        configuration.timeoutIntervalForRequest = 20
        configuration.timeoutIntervalForResource = 20
        configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
        
        sessionManager = Session(configuration: configuration, interceptor: oAuthHandler)
    }
    
    static let defaultError = NSError(domain: "Network", code: -1, userInfo: [NSLocalizedDescriptionKey: "Something went wrong!"])
    static let timeoutError = NSError(domain: "Network", code: -1, userInfo: [NSLocalizedDescriptionKey: "Request timed out"])
    
    // MARK: - RESTful methods
    open func get(_ url: URLConvertible,
                  parameters: [String: Any]? = nil,
                  headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return request(.get, url, parameters: parameters, encoding: URLEncoding.default, headers: headers)
    }
    
    open func post(_ url: URLConvertible,
                   parameters: [String: Any]? = nil,
                   headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return request(.post, url, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
    }
    
    open func put(_ url: URLConvertible,
                  parameters: [String: Any]? = nil,
                  headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return request(.put, url, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
    }
    
    open func patch(_ url: URLConvertible,
                  parameters: [String: Any]? = nil,
                  headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return request(.patch, url, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
    }
    
    open func delete(_ url: URLConvertible,
                     parameters: [String: Any]? = nil,
                     headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return request(.delete, url, parameters: parameters, encoding: JSONEncoding.default, headers: headers)
    }
    
    // MARK: - Common
    open func request(_ method: Alamofire.HTTPMethod,
                      _ url: URLConvertible,
                      parameters: [String: Any]? = nil,
                      encoding: ParameterEncoding = URLEncoding.default,
                      headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
            
        return sessionManager.rx.request(method, url, parameters: parameters, encoding: encoding, headers: HTTPHeaders(headers ?? [:]))
            .handleErrors()
            .observeOn(MainScheduler.instance)
    }
    
    // MARK: - Upload
    open func uploadFiles(_ url: URLConvertible,
                          files: [URL],
                          parameters: [String: Any]? = nil, headers: [String: String]? = nil) -> Observable<(HTTPURLResponse, Data)> {
        return sessionManager.rx.upload(multipartFormData: { multipartFormData in
            for i in 0..<files.count {
                let file = files[i]
                multipartFormData.append(file, withName: "file")
            }
            
            for (key, value) in parameters ?? [:] {
                multipartFormData.append((String(describing: value).data(using: .utf8))!, withName: key)
            }
        }, to: url, method: .post, headers: HTTPHeaders(headers ?? [:]))
        .handleErrors()
        .observeOn(MainScheduler.instance)
    }
}

// MARK: - Utils
extension Observable where Element == (HTTPURLResponse, Data) {
    func apiMap<T: Decodable>() -> Observable<T?> {
        return map {
            do {
                return try JSONDecoder().decode(T.self, from: $0.1)
            } catch {
                debugPrint("PARSE_ERROR: " + error.localizedDescription)
                return nil
            }
        }
    }
}

extension Observable where Element == DataRequest {
    func handleErrors() -> Observable<(HTTPURLResponse, Data)> {
        return flatMap {
            $0.validate(statusCode: [200..<401, 402..<501].joined())
                .validate(contentType: ["application/json"])
                .rx.responseData()
        }
        .catchError({ (error) -> Observable<(HTTPURLResponse, Data)> in
            let nsError = (error as NSError)
            if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
                throw NSError(domain: kCFErrorDomainCFNetwork as String, code: NSURLErrorNotConnectedToInternet, userInfo: [NSLocalizedDescriptionKey : "No internet connection."])
            } else if nsError.code == NSURLErrorTimedOut {
                throw APIService.timeoutError
            } else {
                throw APIService.defaultError
            }
        })
    }
}


extension Observable where Element == UploadRequest {
    func handleErrors() -> Observable<(HTTPURLResponse, Data)> {
        return flatMap {
            $0.validate(statusCode: [200..<401, 402..<501].joined())
                .validate(contentType: ["application/json"])
                .rx.responseData()
        }
        .catchError({ (error) -> Observable<(HTTPURLResponse, Data)> in
            let nsError = (error as NSError)
            if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
                throw NSError(domain: kCFErrorDomainCFNetwork as String, code: NSURLErrorNotConnectedToInternet, userInfo: [NSLocalizedDescriptionKey : "No internet connection."])
            } else if nsError.code == NSURLErrorTimedOut {
                throw APIService.timeoutError
            } else {
                throw APIService.defaultError
            }
        })
    }
}

Parsing data

  • Tools: Quicktype.io
  • Example
struct User:Codable 
{
    var firstName: String
    var lastName: String
    var country: String

    enum CodingKeys: String, CodingKey {
        case firstName = "first_name"
        case lastName = "last_name"
        case country
    }
}