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
}
}