Last active
March 12, 2019 08:18
-
-
Save shawnthroop/3da95620913cccabc4eab812ab46acad to your computer and use it in GitHub Desktop.
A simple and extensible networking library. Supports posting basic multipart form data and type safe query parameters.
This file contains 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
// swift 4.2 | |
public struct Endpoint: Equatable { | |
public typealias Query = Set<QueryItem> | |
public typealias Headers = [HeaderField: String] | |
public enum Method: Equatable { | |
case get | |
case post(Body) | |
} | |
public struct ContentType: Equatable { | |
public static let applicationJSON = ContentType("application/json") | |
public static let applicationXML = ContentType("application/xml") | |
public static let applicationURLEncoded = ContentType("application/x-www-form-urlencoded") | |
public static let audioMPEG = ContentType("audio/mpeg") | |
public static let audioOGG = ContentType("audio/ogg") | |
public static let multipartFormData = ContentType("multipart/form-data") | |
public static let textCSS = ContentType("text/css") | |
public static let textHTML = ContentType("text/html") | |
public static let textXML = ContentType("text/xml") | |
public static let textCSV = ContentType("text/csv") | |
public static let textPlain = ContentType("text/plain") | |
public static let imagePNG = ContentType("image/png") | |
public static let imageJPEG = ContentType("image/jpeg") | |
public static let imageGIF = ContentType("image/gif") | |
public var rawValue: String | |
public var parameters: String? | |
var stringValue: String { | |
return parameters.map({ "\(rawValue); \($0)" }) ?? rawValue | |
} | |
public init(_ rawValue: String, parameters: String? = nil) { | |
self.rawValue = rawValue | |
self.parameters = parameters | |
} | |
public func withParameters(_ parameters: String) -> ContentType { | |
return ContentType(rawValue, parameters: parameters) | |
} | |
public func add(to request: inout URLRequest) { | |
request.addValue(stringValue, forHeaderField: .contentType) | |
} | |
} | |
public enum Body: Equatable { | |
public static func jsonValue<Value: Encodable>(_ value: Value, encoder: JSONEncoder = .init()) throws -> Endpoint.Body { | |
return try .json(encoder.encode(value)) | |
} | |
public static func urlEncodedString(_ str: String, encoding: String.Encoding = .utf8) -> Endpoint.Body? { | |
return str.data(using: encoding).map { .urlEncodedForm($0) } | |
} | |
public static func multipartForm(_ values: [String: String], content: (key: String, data: Data)?) throws -> Body { | |
var form = MultipartForm() | |
try values.forEach { key, value in | |
try form.append(value, forKey: key) | |
} | |
if let (key, data) = content { | |
try form.append(data, forKey: key, filename: key) | |
} | |
try form.appendBoundary(isLast: true) | |
return .multipartForm(form.data, boundary: form.boundary) | |
} | |
case json(Data) | |
case urlEncodedForm(Data) | |
case multipartForm(Data, boundary: String) | |
} | |
public struct HeaderField: Hashable { | |
public static let accept = HeaderField("Accept") | |
public static let authorization = HeaderField("Authorization") | |
public static let connection = HeaderField("Connection") | |
public static let contentLength = HeaderField("Content-Length") | |
public static let contentType = HeaderField("Content-Type") | |
public static let upgrade = HeaderField("Upgrade") | |
public static let warning = HeaderField("Warning") | |
public let rawValue: String | |
public init(_ rawValue: String) { | |
self.rawValue = rawValue | |
} | |
} | |
public struct QueryItem: Hashable { | |
public struct Field: Hashable { | |
public let rawValue: String | |
public init(_ rawValue: String) { | |
self.rawValue = rawValue | |
} | |
} | |
public enum Value: Hashable { | |
static func bool(_ value: Bool) -> Value { | |
return .number(value == true ? 1 : 0) | |
} | |
static func sequence<S: Sequence>(_ value: S) -> Value where S.Element == String { | |
return .text(value.joined(separator: ",")) | |
} | |
static func sequence<S: Sequence>(_ value: S) -> Value where S.Element == Value { | |
return .sequence(value.map({ $0.rawValue })) | |
} | |
case text(String) | |
case number(Int) | |
public var rawValue: String { | |
switch self { | |
case .text(let str): | |
return str | |
case .number(let int): | |
return String(int) | |
} | |
} | |
} | |
public var field: Field | |
public var value: Value | |
public init(_ field: Field, _ value: Value) { | |
self.field = field | |
self.value = value | |
} | |
} | |
public var method: Method | |
public var headers: Headers | |
public var scheme: String | |
public var host: String | |
public var path: String | |
public var query: Query | |
public init(method: Method = .get, headers: Headers = [:], scheme: String = "https", host: String, path: String, query: Query = Query()) { | |
self.method = method | |
self.headers = headers | |
self.scheme = scheme | |
self.host = host | |
self.path = path | |
self.query = query | |
} | |
public var url: URL { | |
var components = URLComponents() | |
components.scheme = scheme | |
components.host = host | |
components.path = path | |
query.nonEmpty.map { query in | |
components.queryItems = query.map { URLQueryItem(name: $0.field.rawValue, value: $0.value.rawValue) } | |
} | |
guard let url = components.url else { | |
preconditionFailure("Could not create url from components: \(components)") | |
} | |
return url | |
} | |
public func byAppending(path: String, headers: Headers = [:], query: Query = [:]) -> Endpoint { | |
var result = self | |
headers.nonEmpty.map { newHeaders in | |
result.headers.merge(newHeaders, uniquingKeysWith: { $1 }) | |
} | |
query.nonEmpty.map { result.query.formUnion($0) } | |
return result | |
} | |
} | |
extension Set: ExpressibleByDictionaryLiteral where Element == Endpoint.QueryItem { | |
public typealias Key = Element.Field | |
public typealias Value = Element.Value | |
public init(dictionaryLiteral elements: (Key, Value)...) { | |
self = elements.reduce(into: Set<Element>(), { $0.insert(Element($1.0, $1.1)) }) | |
} | |
} | |
extension Endpoint.HeaderField: CustomStringConvertible { | |
public var description: String { | |
return rawValue.description | |
} | |
} | |
extension Endpoint.QueryItem: CustomStringConvertible { | |
public var description: String { | |
return "\"\(field)\": \(value)" | |
} | |
} | |
extension Endpoint.QueryItem.Field: CustomStringConvertible { | |
public var description: String { | |
return rawValue.description | |
} | |
} | |
extension Endpoint.QueryItem.Value: ExpressibleByStringLiteral, ExpressibleByIntegerLiteral, ExpressibleByBooleanLiteral, ExpressibleByArrayLiteral, CustomStringConvertible { | |
public init(stringLiteral value: String) { | |
self = .text(value) | |
} | |
public init(integerLiteral value: Int) { | |
self = .number(value) | |
} | |
public init(booleanLiteral value: Bool) { | |
self = .bool(value) | |
} | |
public init(arrayLiteral elements: Endpoint.QueryItem.Value...) { | |
self = .sequence(elements) | |
} | |
public var description: String { | |
switch self { | |
case .text(let str): | |
return "\"\(str)\"" | |
case .number(let int): | |
return String(int) | |
} | |
} | |
} | |
extension URLRequest { | |
mutating func addValue(_ value: String, forHeaderField field: Endpoint.HeaderField) { | |
addValue(value, forHTTPHeaderField: field.rawValue) | |
} | |
func value(forHeaderField field: Endpoint.HeaderField) -> String? { | |
return value(forHTTPHeaderField: field.rawValue) | |
} | |
} | |
extension Collection { | |
public var nonEmpty: Self? { | |
return isEmpty ? nil : self | |
} | |
} |
This file contains 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
// swift 4.2 | |
public struct MultipartForm { | |
public enum EncodingError: Error { | |
case utf8EncodingFailure(String) | |
} | |
private(set) var data: Data | |
public let boundary: String | |
public init(data: Data = Data(), boundary: String = .multipartFormDataBoundary()) { | |
self.data = data | |
self.boundary = boundary | |
} | |
public mutating func appendBoundary(isLast: Bool = false) throws { | |
var ln = "--\(boundary)" | |
if isLast { | |
ln += "--" | |
} | |
try append(ln + .eol) | |
} | |
public mutating func append(_ str: String, forKey key: String) throws { | |
try append(str.utf8Data(), forKey: key, filename: nil) | |
} | |
public mutating func append(_ data: Data, forKey key: String, filename: String?) throws { | |
try appendBoundary() | |
var ln = "Content-Disposition: form-data; name=\"\(key)\"" | |
if let filename = filename { | |
ln += "; filename=\"\(filename)\"" | |
} | |
try append(ln + .eol + .eol) | |
append(data) | |
try append(.eol) | |
} | |
private mutating func append(_ str: String) throws { | |
append(try str.utf8Data()) | |
} | |
private mutating func append(_ data: Data) { | |
self.data.append(data) | |
} | |
} | |
extension String { | |
fileprivate static let eol = "\r\n" | |
public static func multipartFormDataBoundary(seed: String = "terryloves") -> String { | |
return seed + UUID().uuidString | |
} | |
fileprivate func utf8Data() throws -> Data { | |
guard let data = self.data(using: .utf8) else { | |
throw MultipartForm.EncodingError.utf8EncodingFailure(self) | |
} | |
return data | |
} | |
} | |
extension Endpoint.ContentType { | |
public static func multipartFormData(boundary: String) -> Endpoint.ContentType { | |
return Endpoint.ContentType.multipartFormData.withParameters("boundary=\(boundary)") | |
} | |
} |
This file contains 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
// swift 4.2 | |
public struct Resource<A> { | |
public typealias Validate<Response> = (Response) -> Bool | |
public typealias Parse = (Data) throws -> A | |
public let endpoint: Endpoint | |
public let isValidResponse: Validate<HTTPURLResponse> | |
public let parse: Parse | |
public init(endpoint: Endpoint, isValidResponse: @escaping Validate<HTTPURLResponse>, parse: @escaping Parse) { | |
self.endpoint = endpoint | |
self.isValidResponse = isValidResponse | |
self.parse = parse | |
} | |
public init(endpoint: Endpoint, isValidStatusCode: @escaping Validate<Int>, parse: @escaping Parse) { | |
self.init(endpoint: endpoint, isValidResponse: { isValidStatusCode($0.statusCode) }, parse: parse) | |
} | |
public init(endpoint: Endpoint, validStatusCodes: Range<Int> = 200..<300, parse: @escaping Parse) { | |
self.init(endpoint: endpoint, isValidStatusCode: validStatusCodes.contains, parse: parse) | |
} | |
} | |
extension Resource where A: Decodable { | |
public typealias Decode = (Data, JSONDecoder) throws -> A | |
public static func decode(data: Data, using decoder: JSONDecoder) throws -> A { | |
return try decoder.decode(A.self, from: data) | |
} | |
public init(endpoint: Endpoint, isValidResponse: @escaping Validate<HTTPURLResponse>, decode: @escaping Decode = decode) { | |
self.init(endpoint: endpoint, isValidResponse: isValidResponse) { try decode($0, JSONDecoder()) } | |
} | |
public init(endpoint: Endpoint, isValidStatusCode: @escaping Validate<Int>, decode: @escaping Decode = decode) { | |
self.init(endpoint: endpoint, isValidResponse: { isValidStatusCode($0.statusCode) }, decode: decode) | |
} | |
public init(endpoint: Endpoint, validStatusCodes: Range<Int> = 200..<300, decode: @escaping Decode = decode) { | |
self.init(endpoint: endpoint, isValidStatusCode: validStatusCodes.contains, decode: decode) | |
} | |
} | |
extension URLSession { | |
public enum Response<A> { | |
case success(A) | |
case failure(ResponseError) | |
} | |
public enum ResponseError: Error { | |
case invalidStatusCode(Int, message: String) | |
case decodingError(DecodingError) | |
case other(Error?, URLResponse?) | |
init?(response: URLResponse?, isValidResponse: (HTTPURLResponse) -> Bool) { | |
guard let res = response as? HTTPURLResponse, isValidResponse(res) == false else { | |
return nil | |
} | |
let message = HTTPURLResponse.localizedString(forStatusCode: res.statusCode) | |
self = .invalidStatusCode(res.statusCode, message: message) | |
} | |
} | |
@discardableResult | |
public func load<A>(_ resource: Resource<A>, cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, timeout: TimeInterval = 60.0, completion: @escaping (Response<A>) -> Void) -> URLSessionDataTask { | |
func parse(data: Data?, response: URLResponse?, error: Error?) -> Response<A> { | |
if let error = ResponseError(response: response, isValidResponse: resource.isValidResponse) { | |
return .failure(error) | |
} else if let data = data { | |
do { | |
return .success(try resource.parse(data)) | |
} catch let error as DecodingError { | |
return .failure(.decodingError(error)) | |
} catch { | |
return .failure(.other(error, response)) | |
} | |
} else { | |
return .failure(.other(error, response)) | |
} | |
} | |
let task = dataTask(with: resource.request(cachePolicy: cachePolicy, timeout: timeout)) { data, response, error in | |
completion(parse(data: data, response: response, error: error)) | |
} | |
task.resume() | |
return task | |
} | |
} | |
private extension Resource { | |
func request(cachePolicy: URLRequest.CachePolicy, timeout: TimeInterval) -> URLRequest { | |
var result = URLRequest(url: endpoint.url, cachePolicy: cachePolicy, timeoutInterval: timeout) | |
for (field, value) in endpoint.headers { | |
result.addValue(value, forHTTPHeaderField: field.rawValue) | |
} | |
let (method, body) = endpoint.method.values | |
result.httpMethod = method | |
body?.add(to: &result) | |
return result | |
} | |
} | |
private extension Endpoint.Method { | |
var values: (method: String, body: Endpoint.Body?) { | |
switch self { | |
case .get: | |
return ("GET", nil) | |
case .post(let body): | |
return ("POST", body) | |
} | |
} | |
} | |
private extension Endpoint.Body { | |
func add(to request: inout URLRequest) { | |
let (body, contentType) = values | |
contentType.add(to: &request) | |
request.httpBody = body | |
} | |
var values: (body: Data, contentType: Endpoint.ContentType) { | |
switch self { | |
case .json(let data): | |
return (data, .applicationJSON) | |
case .urlEncodedForm(let data): | |
return (data, .applicationURLEncoded) | |
case let .multipartForm(data, boundary): | |
return (data, .multipartFormData(boundary: boundary)) | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment