DDRouter/DDRouter.swift (234 lines of code) (raw):
import Foundation
import RxSwift
// MARK: - DDRouter
public enum DDRouter {
static var sharedSession: URLSession?
static var printToConsole = false
// must call this
public static func initialise(
configuration: URLSessionConfiguration,
session: URLSession? = nil,
printToConsole: Bool = false
) {
sharedSession = session ?? URLSession(configuration: configuration)
Self.printToConsole = printToConsole
}
}
// MARK: - RouterProtocol
public protocol RouterProtocol {
associatedtype Endpoint: EndpointType
associatedtype ErrorModel: APIErrorModelProtocol
func request<T: Decodable>(_ route: Endpoint) -> Single<T>
init(ephemeralSession: Bool)
}
// MARK: - EmptyStruct
public struct EmptyStruct: Decodable {}
public extension RouterProtocol {
typealias Empty = EmptyStruct
}
// MARK: - Router
public class Router<Endpoint: EndpointType, ErrorModel: APIErrorModelProtocol>: RouterProtocol {
var urlSession: URLSession?
public required init(ephemeralSession: Bool = false) {
urlSession = ephemeralSession
? createEphemeralSession()
: DDRouter.sharedSession
}
func createEphemeralSession() -> URLSession {
// clone the current session config, then mutate as needed
// what's the go with this not handling errors
let ephemeralConfig = URLSessionConfiguration.ephemeral
if let sharedConfig = DDRouter.sharedSession?.configuration {
// copy separately protocol classes from current session config
ephemeralConfig.protocolClasses = sharedConfig.protocolClasses
}
return URLSession(configuration: ephemeralConfig)
}
// TODO: do this in the future
// https://medium.com/@danielt1263/retrying-a-network-request-despite-having-an-invalid-token-b8b89340d29
// swiftlint:disable:next function_body_length
public func requestRaw(_ route: Endpoint) -> Single<Data> {
Single.create { [weak self] single in
// bind self or return unknown error
guard let self = self else {
single(.failure(APIError<ErrorModel>.unknownError(nil)))
return Disposables.create()
}
var task: URLSessionTask?
// try to build the request
let request: URLRequest
do {
request = try self.buildRequest(from: route)
} catch {
single(.failure(error))
return Disposables.create()
}
// log the request
// TODO: this should be a noop in prod / when disabled
if DDRouter.printToConsole {
NetworkLogger.log(request: request)
}
// get the session
guard let urlSession = self.urlSession else {
single(.failure(APIError<ErrorModel>.unknownError(nil)))
return Disposables.create()
}
// perform the request
task = urlSession.dataTask(with: request) { data, response, error in
// return any error from the url session task - todo: wrap this error
if let error = error {
single(.failure(error))
return
}
// get the response body or throw null data error
// TODO: technically should throw different error if
// first cast fails
guard
let response = response as? HTTPURLResponse,
let responseData = data else {
single(.failure(APIError<ErrorModel>.nullData))
return
}
// print response
if DDRouter.printToConsole {
// log response - todo: proper logging
NetworkLogger.log(response: response)
// print response data
NetworkLogger.printJSONData(data: responseData)
}
// response switch
switch response.statusCode {
// 204 success with empty response
case 204:
single(.success(responseData))
// 2xx success.
case 200...203, 205...299:
// just return, do the encoding elsewhere
single(.success(responseData))
// 4xx client errors
case 400...499:
// match the actual status code (or unknown error)
guard let statusCode = HTTPStatusCode(rawValue: response.statusCode) else {
single(.failure(APIError<ErrorModel>.unknownError(nil)))
return
}
switch statusCode {
// bad request
case .badRequest:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.badRequest(error)))
// unauthorized
case .unauthorized:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.unauthorized(error)))
return
// TODO: add autoretry back, outside this function
// resource not found
case .notFound:
single(.failure(APIError<ErrorModel>.notFound))
// too many requests
case .tooManyRequests:
single(.failure(APIError<ErrorModel>.tooManyRequests))
// forbidden
case .forbidden:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.forbidden(error)))
// conflict
case .conflict:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.conflict(error)))
// unknown
default:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.unknownError(error)))
}
// 5xx server error
case 500...599:
if
let statusCode = HTTPStatusCode(rawValue: response.statusCode),
statusCode == .serviceUnavailable {
single(.failure(APIError<ErrorModel>.serviceUnavailable))
return
}
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.serverError(error)))
// default / unknown error
default:
let error = try? JSONDecoder().decode(
ErrorModel.self,
from: responseData
)
single(.failure(APIError<ErrorModel>.unknownError(error)))
}
}
// make the request
task?.resume()
return Disposables.create {
task?.cancel()
}
}
.subscribe(on: SerialDispatchQueueScheduler(qos: .background))
.observe(on: MainScheduler.instance)
}
// this returns a single that will always subscribe on a background thread
// and observe on the main thread
public func request<T: Decodable>(_ route: Endpoint) -> Single<T> {
requestRaw(route)
.map { responseData in
// empty
if let result = Empty() as? T {
return result
}
do {
// decode response
let decodedResponse = try JSONDecoder().decode(T.self, from: responseData)
return decodedResponse
} catch {
throw APIError<ErrorModel>.serializeError(error)
}
}
}
// build URLRequest from a given endpoint route
private func buildRequest(from route: EndpointType) throws -> URLRequest {
guard
let urlSession = urlSession,
var urlComponents = URLComponents(
url: route.baseURL.appendingPathComponent(route.path),
resolvingAgainstBaseURL: true
) else {
throw APIError<ErrorModel>.internalError
}
// Build query
if !route.query.isEmpty {
let items = route.query.map { URLQueryItem(name: $0, value: $1) }
urlComponents.queryItems = items
}
// get the url
guard let url = urlComponents.url else {
throw APIError<ErrorModel>.internalError
}
// create a request
var request = URLRequest(
url: url,
cachePolicy: .reloadIgnoringLocalAndRemoteCacheData,
timeoutInterval: urlSession.configuration.timeoutIntervalForRequest
)
// method
request.httpMethod = route.method.rawValue
// headers
if let additionalHeaders = route.headers {
Router.addAdditionalHeaders(additionalHeaders, request: &request)
}
// content type
if request.value(forHTTPHeaderField: "Content-Type") == nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
// encode parameters
switch route.task {
case .request:
break
case let .requestEncodableParameters(
bodyParameters,
urlParameters
):
do {
try ParameterEncoding.encode(
urlRequest: &request,
bodyParameters: bodyParameters,
urlParameters: urlParameters
)
} catch {
throw APIError<ErrorModel>.serializeError(error)
}
case let .requestWithBody(body):
do {
try ParameterEncoding.encode(
urlRequest: &request,
bodyParameters: body,
urlParameters: nil
)
} catch {
throw APIError<ErrorModel>.serializeError(error)
}
case let .requestWithRawBody(body):
request.httpBody = body
}
return request
}
private static func addAdditionalHeaders(
_ additionalHeaders: HTTPHeaders?,
request: inout URLRequest
) {
guard let headers = additionalHeaders else {
return
}
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
}