Created
August 30, 2017 06:48
-
-
Save slycrel/a9b48255e36e6dae3cba816e933c7e1a to your computer and use it in GitHub Desktop.
Simple Swift REST Networking Layer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Copyright © 2016 Jeremy Stone, released under the MIT license. | |
import UIKit | |
import Foundation | |
// Some keychain code relies on Sam Soffes' fantastic keychain library. https://github.com/soffes/SAMKeychain | |
// For this gist, that code is stubbed to do nothing. | |
class SAMKeychain { | |
static func password(forService:String, account:String) -> String? { return nil } | |
static func deletePassword(forService:String, account:String) { } | |
static func setPassword(_ password:String, forService:String, account:String) { } | |
} | |
enum HTTP_STATUS_CODES:Int { | |
// 1xx - Informational | |
case CONTINUE = 100 | |
case SWITCHING_PROTOCOLS = 101 | |
// 2xx - Successful | |
case INVALID_STATUS = 0 | |
case OK = 200 | |
case CREATED = 201 | |
case ACCEPTED = 202 | |
case NON_AUTHORITATIVE_INFO = 203 | |
case NO_CONTENT = 204 | |
case RESET_CONTENT = 205 | |
case PARTIAL_CONTENT = 206 | |
// 3xx - Redirection | |
case MULTIPLE_CHOICES = 300 | |
case MOVED_PERMANENTLY = 301 | |
case FOUND = 302 | |
case SEE_OTHER = 303 | |
case NOT_MODIFIED = 304 | |
case USE_PROXY = 305 | |
case TEMPORARY_REDIRECT = 307 | |
// 4xx - Client Error | |
case BAD_REQUEST = 400 | |
case UNAUTHORIZED = 401 | |
case FORBIDDEN = 403 | |
case NOT_FOUND = 404 | |
case METHOD_NOT_ALLOWED = 405 | |
case NOT_ACCEPTABLE = 406 | |
case PROXY_AUTH_REQUIRED = 407 | |
case CONFLICT = 409 | |
case GONE = 410 | |
case LENGTH_REQUIRED = 411 | |
case PRECONDITION_FAILED = 412 | |
case REQUEST_ENTITY_TOO_LARGE = 413 | |
case REQUEST_URI_TOO_LONG = 414 | |
case UNSUPPORTED_MEDIA_TYPE = 415 | |
case RANGE_NOT_SATISFIABLE = 416 | |
case EXPECTATION_FAILED = 417 | |
// 5xx - Server error | |
case SERVER_ERROR = 500 | |
case NOT_IMPLEMENTED = 501 | |
case BAD_GATEWAY = 502 | |
case SERVICE_UNAVAILABLE = 503 | |
case GATEWAY_TIMEOUT = 504 | |
case HTTP_VERSION_UNSUPPORTED = 505 | |
} | |
typealias NetworkErrorHandler = (_ response:HTTPURLResponse?, _ error:NSError?) -> String | |
typealias NetworkRequestCompletion = (_ data:[String: Any]?, _ response:HTTPURLResponse?, _ error:NSError?) -> Void | |
let NETWORK_REQUEST_LOGGING = true // set this to true for debug logging | |
let API_HOST_DEBUG = "" // define to have a "base" url that gets used for partial paths being passed to networking calls. | |
let API_HOST_PRODUCTION = "" | |
class NetworkHelper: NSObject { | |
// internals | |
private static var _sharedInstance: NetworkHelper? | |
static var sharedInstance:NetworkHelper { | |
get { | |
if _sharedInstance == nil { | |
_sharedInstance = NetworkHelper() | |
} | |
return _sharedInstance! | |
} | |
set(instance) { | |
_sharedInstance = instance | |
} | |
} | |
lazy var session: URLSession = URLSession(configuration: NetworkHelper.sharedInstance.defaultConfiguration()); | |
var host: String | |
let SAVED_USERNAME = "login username" | |
let PASSWORD_STORAGE_NAME = "custom app name here" | |
let HOST_STORAGE_NAME = "NetworkHelperHostStorage" | |
// MARK: - | |
override init() { | |
#if DEBUG // true if "DEBUG=1" is defined in your 'preprocessor macros' build settings for debug builds | |
host = (UserDefaults.standard.object(forKey: HOST_STORAGE_NAME) as? String) ?? API_HOST_DEBUG | |
#else | |
host = (UserDefaults.standard.object(forKey: HOST_STORAGE_NAME) as? String) ?? API_HOST_PRODUCTION | |
#endif | |
} | |
func defaultConfiguration() -> URLSessionConfiguration { | |
let username = savedUsername() | |
let password = savedPassword() | |
let config = URLSessionConfiguration.ephemeral | |
let base64EncodedCredential = Data("\(username):\(password)".utf8).base64EncodedString() | |
let authString = "Basic \(base64EncodedCredential)" | |
config.httpAdditionalHeaders = [ | |
"Accept": "application/json", // modify or add headers here as needed. | |
"Authorization": authString // Basic Authentication. You can change or remove this for another type of authentication or no authentication. | |
] | |
return config | |
} | |
private func savedUsername() -> String { | |
return UserDefaults.standard.string(forKey: SAVED_USERNAME) ?? "username" | |
} | |
private func savedPassword() -> String { | |
return SAMKeychain.password(forService: PASSWORD_STORAGE_NAME, account: savedUsername()) ?? "password" | |
} | |
private func savedPasscode() -> String { | |
return SAMKeychain.password(forService: PASSWORD_STORAGE_NAME, account: "\(savedUsername())-Passcode") ?? "" | |
} | |
private func removePasscode() { | |
SAMKeychain.deletePassword(forService: PASSWORD_STORAGE_NAME, account: "\(savedUsername())-Passcode") | |
} | |
private func removePassword() { | |
SAMKeychain.deletePassword(forService: PASSWORD_STORAGE_NAME, account: savedUsername()) | |
} | |
private func removeUsername() { | |
UserDefaults.standard.removeObject(forKey: SAVED_USERNAME) | |
removePassword() | |
} | |
private func saveUsername(username: String, password: String) { | |
UserDefaults.standard.set(username, forKey: SAVED_USERNAME) | |
SAMKeychain.setPassword(password, forService: PASSWORD_STORAGE_NAME, account: username) | |
} | |
private func savePasscode(passcode:String) { | |
SAMKeychain.setPassword(passcode, forService: PASSWORD_STORAGE_NAME, account: "\(savedUsername())-Passcode") | |
} | |
// MARK: - internals | |
private func makeRequest(inPath:String, params:[String:String]?, postData:Data?, callType:NetworkHelper.NetworkCallType, completion:NetworkRequestCompletion?) -> URLSessionDataTask { | |
let finalPath = inPath.addingPercentEncoding(withAllowedCharacters: CharacterSet.urlPathAllowed) ?? inPath | |
var url = URL(string: finalPath)! | |
if !(url.absoluteString.contains("https://")) { | |
let urlString = inPath.hasPrefix("/") ? String(inPath.characters.dropFirst()) : inPath | |
url = URL(string: "https://\(NetworkHelper.sharedInstance.host)")! | |
url.appendPathComponent(urlString) | |
} | |
if let paramsPassed = params { | |
var paramsArray = Array<URLQueryItem>() | |
for (key, value) in paramsPassed { | |
let item = URLQueryItem(name: key, value: value) | |
paramsArray.append(item) | |
} | |
if var components = URLComponents(string: url.absoluteString) { | |
components.queryItems = paramsArray | |
url = components.url! | |
} | |
} | |
var request = URLRequest(url: url) | |
switch callType { | |
case .GET: break // default | |
case .PUT: | |
request.httpMethod = "PUT" | |
case .POST: | |
request.httpMethod = "POST" | |
case .DELETE: | |
request.httpMethod = "DELETE" | |
} | |
let task = session.dataTask(with: request) { (inData, inResponse, inError) in | |
var dict:[String:Any]? | |
if let data = inData { | |
do { | |
try dict = JSONSerialization.jsonObject(with: data, options:[]) as? [String: Any] | |
if NETWORK_REQUEST_LOGGING { | |
let log = "\n\n*** Request: \(request.url?.absoluteString ?? "?")" + | |
"\n\n*** Response:\n\(String(data: data, encoding: .utf8) ?? "?")" + | |
"\n\n*** Response Status: \((inResponse as? HTTPURLResponse)?.statusCode ?? 0)\n\n" | |
print(log) | |
} | |
} catch { | |
if NETWORK_REQUEST_LOGGING { | |
let str = String(data: data, encoding: .utf8) | |
print("\n\n*** Response URL: \(inResponse?.url?.absoluteString ?? "(Response URL Unavailable)")") | |
print("*** Response Error: \(inError?.localizedDescription ?? "(Blank error)")") | |
print("*** Serialization failed for data:\n\(str ?? "nil")") | |
} | |
dict = ["SRSerializationError": error, | |
"data": data, | |
"error": inError ?? NSNull(), | |
"response": inResponse ?? NSNull()] | |
} | |
if let response = inResponse as? HTTPURLResponse { | |
var error = inError | |
let statusCode = response.statusCode | |
if statusCode < HTTP_STATUS_CODES.OK.rawValue || statusCode >= HTTP_STATUS_CODES.MULTIPLE_CHOICES.rawValue { | |
error = NSError(domain: "RESTErrorDomain", code: statusCode, userInfo: ["data":data]) | |
} | |
if let compl = completion { | |
DispatchQueue.main.async { | |
compl(dict, response, error as NSError?); | |
} | |
} | |
} | |
} | |
} | |
task.resume() // run the task we just built. | |
return task; | |
} | |
// MARK: - Public Functions | |
enum NetworkCallType { | |
case GET, PUT, POST, DELETE | |
} | |
@discardableResult | |
static func GET(_ path:String, completion:NetworkRequestCompletion?) -> URLSessionDataTask { | |
return NetworkHelper.sharedInstance.makeRequest(inPath: path, params: nil, postData: nil, callType: .GET, completion: completion) | |
} | |
@discardableResult | |
static func PUT(_ path:String, params:[String:String]?, completion:NetworkRequestCompletion?) -> URLSessionDataTask { | |
return NetworkHelper.sharedInstance.makeRequest(inPath: path, params: params, postData: nil, callType: .PUT, completion: completion) | |
} | |
@discardableResult | |
static func POST(_ path:String, params:[String:String]?, postData:Data?, completion:NetworkRequestCompletion?) -> URLSessionDataTask { | |
return NetworkHelper.sharedInstance.makeRequest(inPath: path, params: params, postData: postData, callType: .POST, completion: completion) | |
} | |
@discardableResult | |
static func DELETE(_ path:String, completion:NetworkRequestCompletion?) -> URLSessionDataTask { | |
return NetworkHelper.sharedInstance.makeRequest(inPath: path, params: nil, postData: nil, callType: .DELETE, completion: completion) | |
} | |
static func clearSession() { | |
// Note: clearing just the session vs re-making the singleton is effectively the same result. This reduces logical paths of cunstruction vs partial construction. | |
NetworkHelper._sharedInstance = nil | |
} | |
static func setHost(_ host:String) { | |
if host != NetworkHelper.sharedInstance.host { | |
NetworkHelper.sharedInstance.host = host | |
UserDefaults.standard.set(host, forKey: NetworkHelper.sharedInstance.HOST_STORAGE_NAME) | |
self.clearSession() | |
} | |
} | |
static func isDebugServer() -> Bool { | |
return NetworkHelper.sharedInstance.host != API_HOST_PRODUCTION | |
} | |
static func savedUsername() -> String { | |
return NetworkHelper.sharedInstance.savedUsername() | |
} | |
static func savedPassword() -> String { | |
return NetworkHelper.sharedInstance.savedPassword() | |
} | |
static func savedPasscode() -> String { | |
return NetworkHelper.sharedInstance.savedPasscode() | |
} | |
static func saveUsername(_ username: String, password: String) { | |
NetworkHelper.sharedInstance.saveUsername(username: username, password: password) | |
} | |
static func savePasscode(_ passcode: String) { | |
NetworkHelper.sharedInstance.savePasscode(passcode: passcode) | |
} | |
static func removePasscode() { | |
NetworkHelper.sharedInstance.removePasscode() | |
} | |
static func removeUsername() { | |
NetworkHelper.sharedInstance.removeUsername() | |
} | |
static func readableErrorString(response: HTTPURLResponse?, error:NSError?, handler:NetworkErrorHandler?) -> String { | |
var str = "Unexpected Error Processing Request" | |
if let resp = response, let err = error { | |
if resp.statusCode == 0 && err.code == NSURLErrorNotConnectedToInternet { | |
return err.localizedDescription | |
} | |
} | |
if let handl = handler { | |
str = handl(response, error) | |
} else { | |
switch response?.statusCode ?? 0 { | |
case HTTP_STATUS_CODES.SERVER_ERROR.rawValue: | |
str = "Error Processing Request" | |
default: break | |
} | |
} | |
if NetworkHelper.isDebugServer() { | |
DispatchQueue.main.async { | |
let notificationText = "Dev Error Notification: \(error?.code ?? 0), \(error?.localizedDescription ?? "Unknown Error")" | |
print(notificationText) // you can show a developer only notification to the end-user here if desired | |
} | |
} | |
return str | |
} | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment