Skip to content

Instantly share code, notes, and snippets.

@shawnthroop
Last active March 12, 2019 08:18
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shawnthroop/3da95620913cccabc4eab812ab46acad to your computer and use it in GitHub Desktop.
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.
// 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
}
}
// 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)")
}
}
// 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