diff --git a/.gitignore b/.gitignore index 40dcfba..0ec541c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,7 +68,6 @@ playground.xcworkspace Carthage/Build/ # Accio dependency management -Dependencies/ .accio/ # fastlane diff --git a/vrtnu/vrtnu/dependencies/AsyncImage.swift b/vrtnu/vrtnu/dependencies/AsyncImage.swift new file mode 100644 index 0000000..d8271bc --- /dev/null +++ b/vrtnu/vrtnu/dependencies/AsyncImage.swift @@ -0,0 +1,122 @@ +// +// AsyncImage.swift +// AsyncImage +// +// Created by Vadym Bulavin on 2/13/20. +// Copyright © 2020 Vadym Bulavin. All rights reserved. +// +import SwiftUI +import UIKit +import Combine + +protocol ImageCache { + subscript(_ url: URL) -> UIImage? { get set } +} + +struct TemporaryImageCache: ImageCache { + private let cache = NSCache() + + subscript(_ key: URL) -> UIImage? { + get { cache.object(forKey: key as NSURL) } + set { newValue == nil ? cache.removeObject(forKey: key as NSURL) : cache.setObject(newValue!, forKey: key as NSURL) } + } +} + +struct ImageCacheKey: EnvironmentKey { + static let defaultValue: ImageCache = TemporaryImageCache() +} + +extension EnvironmentValues { + var imageCache: ImageCache { + get { self[ImageCacheKey.self] } + set { self[ImageCacheKey.self] = newValue } + } +} + +struct AsyncImage: View { + @StateObject private var loader: ImageLoader + private let placeholder: Placeholder + private let image: (UIImage) -> Image + + init( + url: URL, + @ViewBuilder placeholder: () -> Placeholder, + @ViewBuilder image: @escaping (UIImage) -> Image = Image.init(uiImage:) + ) { + self.placeholder = placeholder() + self.image = image + _loader = StateObject(wrappedValue: ImageLoader(url: url, cache: Environment(\.imageCache).wrappedValue)) + } + + var body: some View { + content + .onAppear(perform: loader.load) + } + + private var content: some View { + Group { + if loader.image != nil { + image(loader.image!) + } else { + placeholder + } + } + } +} + +class ImageLoader: ObservableObject { + @Published var image: UIImage? + + private(set) var isLoading = false + + private let url: URL + private var cache: ImageCache? + private var cancellable: AnyCancellable? + + private static let imageProcessingQueue = DispatchQueue(label: "image-processing") + + init(url: URL, cache: ImageCache? = nil) { + self.url = url + self.cache = cache + } + + deinit { + cancel() + } + + func load() { + guard !isLoading else { return } + + if let image = cache?[url] { + self.image = image + return + } + + cancellable = URLSession.shared.dataTaskPublisher(for: url) + .map { UIImage(data: $0.data) } + .replaceError(with: nil) + .handleEvents(receiveSubscription: { [weak self] _ in self?.onStart() }, + receiveOutput: { [weak self] in self?.cache($0) }, + receiveCompletion: { [weak self] _ in self?.onFinish() }, + receiveCancel: { [weak self] in self?.onFinish() }) + .subscribe(on: Self.imageProcessingQueue) + .receive(on: DispatchQueue.main) + .sink { [weak self] in self?.image = $0 } + } + + func cancel() { + cancellable?.cancel() + } + + private func onStart() { + isLoading = true + } + + private func onFinish() { + isLoading = false + } + + private func cache(_ image: UIImage?) { + image.map { cache?[url] = $0 } + } +} diff --git a/vrtnu/vrtnu/dependencies/Just.swift b/vrtnu/vrtnu/dependencies/Just.swift new file mode 100644 index 0000000..487eb06 --- /dev/null +++ b/vrtnu/vrtnu/dependencies/Just.swift @@ -0,0 +1,1130 @@ +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +#if os(Linux) +import Dispatch +#endif + +// stolen from python-requests +let statusCodeDescriptions = [ + // Informational. + 100: "continue", + 101: "switching protocols", + 102: "processing", + 103: "checkpoint", + 122: "uri too long", + 200: "ok", + 201: "created", + 202: "accepted", + 203: "non authoritative info", + 204: "no content", + 205: "reset content", + 206: "partial content", + 207: "multi status", + 208: "already reported", + 226: "im used", + + // Redirection. + 300: "multiple choices", + 301: "moved permanently", + 302: "found", + 303: "see other", + 304: "not modified", + 305: "use proxy", + 306: "switch proxy", + 307: "temporary redirect", + 308: "permanent redirect", + + // Client Error. + 400: "bad request", + 401: "unauthorized", + 402: "payment required", + 403: "forbidden", + 404: "not found", + 405: "method not allowed", + 406: "not acceptable", + 407: "proxy authentication required", + 408: "request timeout", + 409: "conflict", + 410: "gone", + 411: "length required", + 412: "precondition failed", + 413: "request entity too large", + 414: "request uri too large", + 415: "unsupported media type", + 416: "requested range not satisfiable", + 417: "expectation failed", + 418: "im a teapot", + 422: "unprocessable entity", + 423: "locked", + 424: "failed dependency", + 425: "unordered collection", + 426: "upgrade required", + 428: "precondition required", + 429: "too many requests", + 431: "header fields too large", + 444: "no response", + 449: "retry with", + 450: "blocked by windows parental controls", + 451: "unavailable for legal reasons", + 499: "client closed request", + + // Server Error. + 500: "internal server error", + 501: "not implemented", + 502: "bad gateway", + 503: "service unavailable", + 504: "gateway timeout", + 505: "http version not supported", + 506: "variant also negotiates", + 507: "insufficient storage", + 509: "bandwidth limit exceeded", + 510: "not extended", +] + +public enum HTTPFile { + case url(URL, String?) // URL to a file, mimetype + case data(String, Foundation.Data, String?) // filename, data, mimetype + case text(String, String, String?) // filename, text, mimetype +} + +// Supported request types +public enum HTTPMethod: String { + case delete = "DELETE" + case get = "GET" + case head = "HEAD" + case options = "OPTIONS" + case patch = "PATCH" + case post = "POST" + case put = "PUT" +} + +extension URLResponse { + var HTTPHeaders: [String: String] { + return (self as? HTTPURLResponse)?.allHeaderFields as? [String: String] + ?? [:] + } +} + +public protocol URLComponentsConvertible { + var urlComponents: URLComponents? { get } +} + +extension String: URLComponentsConvertible { + public var urlComponents: URLComponents? { + return URLComponents(string: self) + } +} + +extension URL: URLComponentsConvertible { + public var urlComponents: URLComponents? { + return URLComponents(url: self, resolvingAgainstBaseURL: true) + } +} + +/// The only reason this is not a struct is the requirements for +/// lazy evaluation of `headers` and `cookies`, which is mutating the +/// struct. This would make those properties unusable with `HTTPResult`s +/// declared with `let` +public final class HTTPResult : NSObject { + public final var content: Data? + public var response: URLResponse? + public var error: Error? + public var request: URLRequest? { return task?.originalRequest } + public var task: URLSessionTask? + public var encoding = String.Encoding.utf8 + public var JSONReadingOptions = JSONSerialization.ReadingOptions(rawValue: 0) + + public var reason: String { + if let code = self.statusCode, let text = statusCodeDescriptions[code] { + return text + } + + if let error = self.error { + return error.localizedDescription + } + return "Unknown" + } + + public var isRedirect: Bool { + if let code = self.statusCode { + return code >= 300 && code < 400 + } + return false + } + + public var isPermanentRedirect: Bool { + return self.statusCode == 301 + } + + public override var description: String { + if let status = statusCode, + let urlString = request?.url?.absoluteString, + let method = request?.httpMethod + { + return "\(method) \(urlString) \(status)" + } else { + return "" + } + } + + public init(data: Data?, response: URLResponse?, error: Error?, + task: URLSessionTask?) + { + self.content = data + self.response = response + self.error = error + self.task = task + } + + public var json: Any? { + return content.flatMap { + try? JSONSerialization.jsonObject(with: $0, options: JSONReadingOptions) + } + } + + public var statusCode: Int? { + return (self.response as? HTTPURLResponse)?.statusCode + } + + public var text: String? { + return content.flatMap { String(data: $0, encoding: encoding) } + } + + public lazy var headers: CaseInsensitiveDictionary = { + return CaseInsensitiveDictionary( + dictionary: self.response?.HTTPHeaders ?? [:]) + }() + + public lazy var cookies: [String: HTTPCookie] = { + let foundCookies: [HTTPCookie] + if let headers = self.response?.HTTPHeaders, let url = self.response?.url { + foundCookies = HTTPCookie.cookies(withResponseHeaderFields: headers, + for: url) + } else { + foundCookies = [] + } + var result: [String: HTTPCookie] = [:] + for cookie in foundCookies { + result[cookie.name] = cookie + } + return result + }() + + public var ok: Bool { + return statusCode != nil && !(statusCode! >= 400 && statusCode! < 600) + } + + public var url: URL? { + return response?.url + } + + public lazy var links: [String: [String: String]] = { + var result = [String: [String: String]]() + guard let content = self.headers["link"] else { + return result + } + content.components(separatedBy: ", ").forEach { s in + let linkComponents = s.components(separatedBy: ";") + .map { $0.trimmingCharacters(in: CharacterSet.whitespaces) } + // although a link without a rel is valid, there's no way to reference it. + if linkComponents.count > 1 { + let url = linkComponents.first! + let start = url.index(url.startIndex, offsetBy: 1) + let end = url.index(url.endIndex, offsetBy: -1) + let urlRange = start..: Collection, + ExpressibleByDictionaryLiteral +{ + private var _data: [Key: Value] = [:] + private var _keyMap: [String: Key] = [:] + + public typealias Element = (key: Key, value: Value) + public typealias Index = DictionaryIndex + public var startIndex: Index { + return _data.startIndex + } + public var endIndex: Index { + return _data.endIndex + } + public func index(after: Index) -> Index { + return _data.index(after: after) + } + + public var count: Int { + assert(_data.count == _keyMap.count, "internal keys out of sync") + return _data.count + } + + public var isEmpty: Bool { + return _data.isEmpty + } + + public init(dictionaryLiteral elements: (Key, Value)...) { + for (key, value) in elements { + _keyMap["\(key)".lowercased()] = key + _data[key] = value + } + } + + public init(dictionary: [Key: Value]) { + for (key, value) in dictionary { + _keyMap["\(key)".lowercased()] = key + _data[key] = value + } + } + + public subscript (position: Index) -> Element { + return _data[position] + } + + public subscript (key: Key) -> Value? { + get { + if let realKey = _keyMap["\(key)".lowercased()] { + return _data[realKey] + } + return nil + } + set(newValue) { + let lowerKey = "\(key)".lowercased() + if _keyMap[lowerKey] == nil { + _keyMap[lowerKey] = key + } + _data[_keyMap[lowerKey]!] = newValue + } + } + + public func makeIterator() -> DictionaryIterator { + return _data.makeIterator() + } + + public var keys: Dictionary.Keys { + return _data.keys + } + public var values: Dictionary.Values { + return _data.values + } +} + +typealias TaskID = Int +public typealias Credentials = (username: String, password: String) +public typealias TaskProgressHandler = (HTTPProgress) -> Void +typealias TaskCompletionHandler = (HTTPResult) -> Void + +struct TaskConfiguration { + let credential: Credentials? + let redirects: Bool + let originalRequest: URLRequest? + var data: Data + let progressHandler: TaskProgressHandler? + let completionHandler: TaskCompletionHandler? +} + +public struct JustSessionDefaults { + public var JSONReadingOptions: JSONSerialization.ReadingOptions + public var JSONWritingOptions: JSONSerialization.WritingOptions + public var headers: [String: String] + public var multipartBoundary: String + public var credentialPersistence: URLCredential.Persistence + public var encoding: String.Encoding + public var cachePolicy: NSURLRequest.CachePolicy + public init( + JSONReadingOptions: JSONSerialization.ReadingOptions = + JSONSerialization.ReadingOptions(rawValue: 0), + JSONWritingOptions: JSONSerialization.WritingOptions = + JSONSerialization.WritingOptions(rawValue: 0), + headers: [String: String] = [:], + multipartBoundary: String = "Ju5tH77P15Aw350m3", + credentialPersistence: URLCredential.Persistence = .forSession, + encoding: String.Encoding = String.Encoding.utf8, + cachePolicy: NSURLRequest.CachePolicy = .reloadIgnoringLocalCacheData) + { + self.JSONReadingOptions = JSONReadingOptions + self.JSONWritingOptions = JSONWritingOptions + self.headers = headers + self.multipartBoundary = multipartBoundary + self.encoding = encoding + self.credentialPersistence = credentialPersistence + self.cachePolicy = cachePolicy + } +} + + +public struct HTTPProgress { + public enum `Type` { + case upload + case download + } + + public let type: Type + public let bytesProcessed: Int64 + public let bytesExpectedToProcess: Int64 + public var chunk: Data? + public var percent: Float { + return Float(bytesProcessed) / Float(bytesExpectedToProcess) + } +} + +let errorDomain = "net.justhttp.Just" + +public protocol JustAdaptor { + func request( + _ method: HTTPMethod, + url: URLComponentsConvertible, + params: [String: Any], + data: [String: Any], + json: Any?, + headers: [String: String], + files: [String: HTTPFile], + auth: Credentials?, + cookies: [String: String], + redirects: Bool, + timeout: Double?, + urlQuery: String?, + requestBody: Data?, + asyncProgressHandler: TaskProgressHandler?, + asyncCompletionHandler: ((HTTPResult) -> Void)? + ) -> HTTPResult + + init(session: URLSession?, defaults: JustSessionDefaults?) +} + +public struct JustOf { + let adaptor: Adaptor + public init(session: URLSession? = nil, + defaults: JustSessionDefaults? = nil) + { + adaptor = Adaptor(session: session, defaults: defaults) + } +} + +extension JustOf { + + @discardableResult + public func request( + _ method: HTTPMethod, + url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + return adaptor.request( + method, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func delete( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .delete, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func get( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .get, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func head( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .head, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func options( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + return adaptor.request( + .options, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func patch( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .patch, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func post( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .post, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } + + @discardableResult + public func put( + _ url: URLComponentsConvertible, + params: [String: Any] = [:], + data: [String: Any] = [:], + json: Any? = nil, + headers: [String: String] = [:], + files: [String: HTTPFile] = [:], + auth: (String, String)? = nil, + cookies: [String: String] = [:], + allowRedirects: Bool = true, + timeout: Double? = nil, + urlQuery: String? = nil, + requestBody: Data? = nil, + asyncProgressHandler: (TaskProgressHandler)? = nil, + asyncCompletionHandler: ((HTTPResult) -> Void)? = nil + ) -> HTTPResult { + + return adaptor.request( + .put, + url: url, + params: params, + data: data, + json: json, + headers: headers, + files: files, + auth: auth, + cookies: cookies, + redirects: allowRedirects, + timeout: timeout, + urlQuery: urlQuery, + requestBody: requestBody, + asyncProgressHandler: asyncProgressHandler, + asyncCompletionHandler: asyncCompletionHandler + ) + } +} + +public final class HTTP: NSObject, URLSessionDelegate, JustAdaptor { + + public init(session: URLSession? = nil, + defaults: JustSessionDefaults? = nil) + { + super.init() + if let initialSession = session { + self.session = initialSession + } else { + self.session = URLSession(configuration: URLSessionConfiguration.default, + delegate: self, delegateQueue: nil) + } + + if let initialDefaults = defaults { + self.defaults = initialDefaults + } else { + self.defaults = JustSessionDefaults() + } + } + + var taskConfigs: [TaskID: TaskConfiguration]=[:] + var defaults: JustSessionDefaults! + var session: URLSession! + var invalidURLError = NSError( + domain: errorDomain, + code: 0, + userInfo: [NSLocalizedDescriptionKey: "[Just] URL is invalid"] + ) + + var syncResultAccessError = NSError( + domain: errorDomain, + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: + "[Just] You are accessing asynchronous result synchronously." + ] + ) + + func queryComponents(_ key: String, _ value: Any) -> [(String, String)] { + var components: [(String, String)] = [] + if let dictionary = value as? [String: Any] { + for (nestedKey, value) in dictionary { + components += queryComponents("\(key)[\(nestedKey)]", value) + } + } else if let array = value as? [Any] { + for value in array { + components += queryComponents("\(key)", value) + } + } else { + components.append(( + percentEncodeString(key), + percentEncodeString("\(value)")) + ) + } + + return components + } + + func query(_ parameters: [String: Any]) -> String { + var components: [(String, String)] = [] + for (key, value) in parameters.sorted(by: { $0.key < $1.key }) { + components += self.queryComponents(key, value) + } + + return (components.map { "\($0)=\($1)" }).joined(separator: "&") + } + + func percentEncodeString(_ originalObject: Any) -> String { + if originalObject is NSNull { + return "null" + } else { + var reserved = CharacterSet.urlQueryAllowed + reserved.remove(charactersIn: ": #[]@!$&'()*+, ;=") + return String(describing: originalObject) + .addingPercentEncoding(withAllowedCharacters: reserved) ?? "" + } + } + + + func makeTask(_ request: URLRequest, configuration: TaskConfiguration) + -> URLSessionDataTask? + { + let task = session.dataTask(with: request) + taskConfigs[task.taskIdentifier] = configuration + return task + } + + func synthesizeMultipartBody(_ data: [String: Any], files: [String: HTTPFile]) + -> Data? + { + var body = Data() + let boundary = "--\(self.defaults.multipartBoundary)\r\n" + .data(using: defaults.encoding)! + for (k, v) in data { + let valueToSend: Any = v is NSNull ? "null" : v + body.append(boundary) + body.append("Content-Disposition: form-data; name=\"\(k)\"\r\n\r\n" + .data(using: defaults.encoding)!) + body.append("\(valueToSend)\r\n".data(using: defaults.encoding)!) + } + + for (k, v) in files { + body.append(boundary) + var partContent: Data? = nil + var partFilename: String? = nil + var partMimetype: String? = nil + switch v { + case let .url(URL, mimetype): + partFilename = URL.lastPathComponent + if let URLContent = try? Data(contentsOf: URL) { + partContent = URLContent + } + partMimetype = mimetype + case let .text(filename, text, mimetype): + partFilename = filename + if let textData = text.data(using: defaults.encoding) { + partContent = textData + } + partMimetype = mimetype + case let .data(filename, data, mimetype): + partFilename = filename + partContent = data + partMimetype = mimetype + } + if let content = partContent, let filename = partFilename { + let dispose = "Content-Disposition: form-data; name=\"\(k)\"; filename=\"\(filename)\"\r\n" + body.append(dispose.data(using: defaults.encoding)!) + if let type = partMimetype { + body.append( + "Content-Type: \(type)\r\n\r\n".data(using: defaults.encoding)!) + } else { + body.append("\r\n".data(using: defaults.encoding)!) + } + body.append(content) + body.append("\r\n".data(using: defaults.encoding)!) + } + } + + if body.count > 0 { + body.append("--\(self.defaults.multipartBoundary)--\r\n" + .data(using: defaults.encoding)!) + } + + return body + } + + public func synthesizeRequest( + _ method: HTTPMethod, + url: URLComponentsConvertible, + params: [String: Any], + data: [String: Any], + json: Any?, + headers: CaseInsensitiveDictionary, + files: [String: HTTPFile], + auth: Credentials?, + timeout: Double?, + urlQuery: String?, + requestBody: Data? + ) -> URLRequest? { + if var urlComponents = url.urlComponents { + let queryString = query(params) + + if queryString.count > 0 { + urlComponents.percentEncodedQuery = queryString + } + + var finalHeaders = headers + var contentType: String? = nil + var body: Data? + + if let requestData = requestBody { + body = requestData + } else if files.count > 0 { + body = synthesizeMultipartBody(data, files: files) + let bound = self.defaults.multipartBoundary + contentType = "multipart/form-data; boundary=\(bound)" + } else { + if let requestJSON = json { + contentType = "application/json" + body = try? JSONSerialization.data(withJSONObject: requestJSON, + options: defaults.JSONWritingOptions) + } else { + if data.count > 0 { + // assume user wants JSON if she is using this header + if headers["content-type"]?.lowercased() == "application/json" { + body = try? JSONSerialization.data(withJSONObject: data, + options: defaults.JSONWritingOptions) + } else { + contentType = "application/x-www-form-urlencoded" + body = query(data).data(using: defaults.encoding) + } + } + } + } + + if let contentTypeValue = contentType { + finalHeaders["Content-Type"] = contentTypeValue + } + + if let auth = auth, + let utf8 = "\(auth.0):\(auth.1)".data(using: String.Encoding.utf8) + { + finalHeaders["Authorization"] = "Basic \(utf8.base64EncodedString())" + } + if let URL = urlComponents.url { + var request = URLRequest(url: URL) + request.cachePolicy = defaults.cachePolicy + request.httpBody = body + request.httpMethod = method.rawValue + if let requestTimeout = timeout { + request.timeoutInterval = requestTimeout + } + + for (k, v) in defaults.headers { + request.addValue(v, forHTTPHeaderField: k) + } + + for (k, v) in finalHeaders { + request.addValue(v, forHTTPHeaderField: k) + } + return request + } + + } + return nil + } + + public func request( + _ method: HTTPMethod, + url: URLComponentsConvertible, + params: [String: Any], + data: [String: Any], + json: Any?, + headers: [String: String], + files: [String: HTTPFile], + auth: Credentials?, + cookies: [String: String], + redirects: Bool, + timeout: Double?, + urlQuery: String?, + requestBody: Data?, + asyncProgressHandler: TaskProgressHandler?, + asyncCompletionHandler: ((HTTPResult) -> Void)?) -> HTTPResult { + + let isSynchronous = asyncCompletionHandler == nil + let semaphore = DispatchSemaphore(value: 0) + var requestResult: HTTPResult = HTTPResult(data: nil, response: nil, + error: syncResultAccessError, task: nil) + + let caseInsensitiveHeaders = CaseInsensitiveDictionary( + dictionary: headers) + guard let request = synthesizeRequest(method, url: url, + params: params, data: data, json: json, headers: caseInsensitiveHeaders, + files: files, auth: auth, timeout: timeout, urlQuery: urlQuery, + requestBody: requestBody) else + { + let erronousResult = HTTPResult(data: nil, response: nil, + error: invalidURLError, task: nil) + if let handler = asyncCompletionHandler { + handler(erronousResult) + } + return erronousResult + } + addCookies(request.url!, newCookies: cookies) + let config = TaskConfiguration( + credential: auth, + redirects: redirects, + originalRequest: request, + data: Data(), + progressHandler: asyncProgressHandler) + { result in + if let handler = asyncCompletionHandler { + handler(result) + } + if isSynchronous { + requestResult = result + semaphore.signal() + } + } + + if let task = makeTask(request, configuration: config) { + task.resume() + } + + if isSynchronous { + let timeout = timeout.flatMap { DispatchTime.now() + $0 } + ?? DispatchTime.distantFuture + _ = semaphore.wait(timeout: timeout) + return requestResult + } + return requestResult + } + + func addCookies(_ URL: Foundation.URL, newCookies: [String: String]) { + for (k, v) in newCookies { + if let cookie = HTTPCookie(properties: [ + HTTPCookiePropertyKey.name: k, + HTTPCookiePropertyKey.value: v, + HTTPCookiePropertyKey.originURL: URL, + HTTPCookiePropertyKey.path: "/" + ]) + { + session.configuration.httpCookieStorage?.setCookie(cookie) + } + } + } +} + +extension HTTP: URLSessionTaskDelegate, URLSessionDataDelegate { + public func urlSession(_ session: URLSession, task: URLSessionTask, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, + URLCredential?) -> Void) + { + var endCredential: URLCredential? = nil + + if let taskConfig = taskConfigs[task.taskIdentifier], + let credential = taskConfig.credential + { + if !(challenge.previousFailureCount > 0) { + endCredential = URLCredential( + user: credential.0, + password: credential.1, + persistence: self.defaults.credentialPersistence + ) + } + } + + completionHandler(.useCredential, endCredential) + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void) + { + if let allowRedirects = taskConfigs[task.taskIdentifier]?.redirects { + if !allowRedirects { + completionHandler(nil) + return + } + completionHandler(request) + } else { + completionHandler(request) + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + didSendBodyData bytesSent: Int64, totalBytesSent: Int64, + totalBytesExpectedToSend: Int64) + { + if let handler = taskConfigs[task.taskIdentifier]?.progressHandler { + handler( + HTTPProgress( + type: .upload, + bytesProcessed: totalBytesSent, + bytesExpectedToProcess: totalBytesExpectedToSend, + chunk: nil + ) + ) + } + } + + public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, + didReceive data: Data) + { + if let handler = taskConfigs[dataTask.taskIdentifier]?.progressHandler { + handler( + HTTPProgress( + type: .download, + bytesProcessed: dataTask.countOfBytesReceived, + bytesExpectedToProcess: dataTask.countOfBytesExpectedToReceive, + chunk: data + ) + ) + } + if taskConfigs[dataTask.taskIdentifier]?.data != nil { + taskConfigs[dataTask.taskIdentifier]?.data.append(data) + } + } + + public func urlSession(_ session: URLSession, task: URLSessionTask, + didCompleteWithError error: Error?) + { + if let config = taskConfigs[task.taskIdentifier], + let handler = config.completionHandler + { + let result = HTTPResult( + data: config.data, + response: task.response, + error: error, + task: task + ) + result.JSONReadingOptions = self.defaults.JSONReadingOptions + result.encoding = self.defaults.encoding + handler(result) + } + taskConfigs.removeValue(forKey: task.taskIdentifier) + } +} + +public let Just = JustOf()