Move local modules into a folder named Modules.

This commit is contained in:
Brent Simmons
2024-07-06 21:07:05 -07:00
parent 14bcef0f9a
commit d50b5818ac
491 changed files with 76 additions and 52 deletions

View File

@@ -0,0 +1,27 @@
//
// Dictionary+Web.swift
// RSWeb
//
// Created by Brent Simmons on 1/13/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import Foundation
public extension Dictionary where Key == String, Value == String {
/// Translates a dictionary into a string like `foo=bar&param2=some%20thing`.
var urlQueryString: String? {
var queryItems = [URLQueryItem]()
for (key, value) in self {
queryItems.append(URLQueryItem(name: key, value: value))
}
var components = URLComponents()
components.queryItems = queryItems
let s = components.percentEncodedQuery
return s == nil || s!.isEmpty ? nil : s
}
}

View File

@@ -0,0 +1,32 @@
//
// DownloadObject.swift
// RSWeb
//
// Created by Brent Simmons on 8/3/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public final class DownloadObject: Hashable {
public let url: URL
public var data = Data()
public init(url: URL) {
self.url = url
}
// MARK: - Hashable
public func hash(into hasher: inout Hasher) {
hasher.combine(url)
}
// MARK: - Equatable
public static func ==(lhs: DownloadObject, rhs: DownloadObject) -> Bool {
return lhs.url == rhs.url && lhs.data == rhs.data
}
}

View File

@@ -0,0 +1,108 @@
//
// DownloadProgress.swift
// RSWeb
//
// Created by Brent Simmons on 9/17/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os
public extension Notification.Name {
static let DownloadProgressDidChange = Notification.Name(rawValue: "DownloadProgressDidChange")
}
public final class DownloadProgress: Sendable {
public struct TaskCount: Sendable {
public var numberOfTasks = 0
public var numberCompleted = 0
public var numberRemaining: Int {
let n = numberOfTasks - numberCompleted
assert(n >= 0)
return n
}
}
private let taskCount: OSAllocatedUnfairLock<TaskCount>
public var taskCounts: TaskCount {
taskCount.withLock { $0 }
}
public var isComplete: Bool {
taskCount.withLock { $0.numberRemaining < 1 }
}
public init(numberOfTasks: Int) {
assert(numberOfTasks >= 0)
self.taskCount = OSAllocatedUnfairLock(initialState: TaskCount(numberOfTasks: numberOfTasks))
}
public func addTask() {
addTasks(1)
}
public func addTasks(_ n: Int) {
assert(n > 0)
taskCount.withLock {
$0.numberOfTasks = $0.numberOfTasks + n
}
postDidChangeNotification()
}
public func completeTask() {
completeTasks(1)
}
public func completeTasks(_ tasks: Int) {
taskCount.withLock { taskCount in
taskCount.numberCompleted = taskCount.numberCompleted + tasks
assert(taskCount.numberCompleted <= taskCount.numberOfTasks)
}
postDidChangeNotification()
}
public func clear() {
taskCount.withLock { taskCount in
var didChange = false
if taskCount.numberOfTasks != 0 {
taskCount.numberOfTasks = 0
didChange = true
}
if taskCount.numberCompleted != 0 {
taskCount.numberCompleted = 0
didChange = true
}
if didChange {
postDidChangeNotification()
}
}
}
}
// MARK: - Private
private extension DownloadProgress {
func postDidChangeNotification() {
Task { @MainActor in
NotificationCenter.default.post(name: .DownloadProgressDidChange, object: self)
}
}
}

View File

@@ -0,0 +1,349 @@
//
// DownloadSession.swift
// RSWeb
//
// Created by Brent Simmons on 3/12/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os
// To download things: call `download` with a set of identifiers (String). Redirects are followed automatically.
public protocol DownloadSessionDelegate: AnyObject {
// DownloadSession will add User-Agent header to request returned by delegate
@MainActor func downloadSession(_ downloadSession: DownloadSession, requestForIdentifier: String) -> URLRequest?
@MainActor func downloadSession(_ downloadSession: DownloadSession, downloadDidCompleteForIdentifier: String, response: URLResponse?, data: Data?, error: Error?)
@MainActor func downloadSession(_ downloadSession: DownloadSession, shouldContinueAfterReceivingData: Data, identifier: String) -> Bool
@MainActor func downloadSession(_ downloadSession: DownloadSession, didReceiveUnexpectedResponse: URLResponse, identifier: String)
@MainActor func downloadSession(_ downloadSession: DownloadSession, didReceiveNotModifiedResponse: URLResponse, identifier: String)
@MainActor func downloadSession(_ downloadSession: DownloadSession, didDiscardDuplicateIdentifier: String)
@MainActor func downloadSessionDidComplete(_ downloadSession: DownloadSession)
}
@MainActor @objc public final class DownloadSession: NSObject {
public weak var delegate: DownloadSessionDelegate?
public var downloadProgress = DownloadProgress(numberOfTasks: 0)
private var urlSession: URLSession!
private var tasksInProgress = Set<URLSessionTask>()
private var tasksPending = Set<URLSessionTask>()
private var taskIdentifierToInfoDictionary = [Int: DownloadInfo]()
private var allIdentifiers = Set<String>()
private var redirectCache = [String: String]()
private var queue = [String]()
override public init() {
super.init()
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
sessionConfiguration.timeoutIntervalForRequest = 15.0
sessionConfiguration.httpShouldSetCookies = false
sessionConfiguration.httpCookieAcceptPolicy = .never
sessionConfiguration.httpMaximumConnectionsPerHost = 1
sessionConfiguration.httpCookieStorage = nil
sessionConfiguration.urlCache = nil
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
self.urlSession = URLSession(configuration: sessionConfiguration, delegate: self, delegateQueue: OperationQueue.main)
}
deinit {
urlSession.invalidateAndCancel()
}
// MARK: - API
public func cancelAll() async {
downloadProgress.clear()
let (dataTasks, uploadTasks, downloadTasks) = await urlSession.tasks
for dataTask in dataTasks {
dataTask.cancel()
}
for uploadTask in uploadTasks {
uploadTask.cancel()
}
for downloadTask in downloadTasks {
downloadTask.cancel()
}
}
public func download(_ identifiers: Set<String>) {
for identifier in identifiers {
if !allIdentifiers.contains(identifier) {
allIdentifiers.insert(identifier)
addDataTask(identifier)
} else {
delegate?.downloadSession(self, didDiscardDuplicateIdentifier: identifier)
}
}
}
}
// MARK: - URLSessionTaskDelegate
extension DownloadSession: URLSessionTaskDelegate {
nonisolated public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
MainActor.assumeIsolated {
guard let info = infoForTask(task) else {
assertionFailure("Missing info for task in DownloadSession didCompleteWithError")
return
}
if let response = info.urlResponse, response.statusIsOK {
delegate?.downloadSession(self, downloadDidCompleteForIdentifier: info.identifier, response: info.urlResponse, data: info.data, error: error)
}
removeTask(task)
}
}
nonisolated public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
MainActor.assumeIsolated {
if response.statusCode == HTTPResponseCode.redirectTemporary || response.statusCode == HTTPResponseCode.redirectVeryTemporary {
if let oldURLString = task.originalRequest?.url?.absoluteString, let newURLString = request.url?.absoluteString {
cacheRedirect(oldURLString, newURLString)
}
}
completionHandler(request)
}
}
}
// MARK: - URLSessionDataDelegate
extension DownloadSession: URLSessionDataDelegate {
nonisolated public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
MainActor.assumeIsolated {
tasksInProgress.insert(dataTask)
tasksPending.remove(dataTask)
let info = infoForTask(dataTask)
let identifier = info?.identifier
info?.urlResponse = response
if response.forcedStatusCode == HTTPResponseCode.notModified {
if let identifier {
delegate?.downloadSession(self, didReceiveNotModifiedResponse: response, identifier: identifier)
}
completionHandler(.allow)
return
}
if !response.statusIsOK {
if let identifier {
delegate?.downloadSession(self, didReceiveUnexpectedResponse: response, identifier: identifier)
}
completionHandler(.cancel)
return
}
addDataTaskFromQueueIfNecessary()
completionHandler(.allow)
}
}
nonisolated public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
MainActor.assumeIsolated {
guard let delegate, let info = infoForTask(dataTask) else {
return
}
info.addData(data)
if !delegate.downloadSession(self, shouldContinueAfterReceivingData: info.data!, identifier: info.identifier) {
info.canceled = true
dataTask.cancel()
removeTask(dataTask)
}
}
}
}
// MARK: - Private
private extension DownloadSession {
func addDataTask(_ identifier: String) {
downloadProgress.addTask()
guard tasksPending.count < 500 else {
queue.insert(identifier, at: 0)
return
}
guard let request = delegate?.downloadSession(self, requestForIdentifier: identifier) else {
downloadProgress.completeTask()
return
}
var requestToUse = request
// If received permanent redirect earlier, use that URL.
if let urlString = request.url?.absoluteString, let redirectedURLString = cachedRedirectForURLString(urlString) {
if let redirectedURL = URL(string: redirectedURLString) {
requestToUse.url = redirectedURL
}
}
requestToUse.httpShouldHandleCookies = false
let task = urlSession.dataTask(with: requestToUse)
let info = DownloadInfo(identifier, urlRequest: requestToUse)
taskIdentifierToInfoDictionary[task.taskIdentifier] = info
tasksPending.insert(task)
task.resume()
updateDownloadProgress()
}
func addDataTaskFromQueueIfNecessary() {
guard tasksPending.count < 500, let identifier = queue.popLast() else {
return
}
addDataTask(identifier)
}
func infoForTask(_ task: URLSessionTask) -> DownloadInfo? {
return taskIdentifierToInfoDictionary[task.taskIdentifier]
}
func removeTask(_ task: URLSessionTask) {
tasksInProgress.remove(task)
tasksPending.remove(task)
taskIdentifierToInfoDictionary[task.taskIdentifier] = nil
addDataTaskFromQueueIfNecessary()
downloadProgress.completeTask()
updateDownloadProgress()
if tasksInProgress.count + tasksPending.count + queue.count < 1 { // Finished?
allIdentifiers = Set<String>()
delegate?.downloadSessionDidComplete(self)
downloadProgress.clear()
}
}
func updateDownloadProgress() {
// downloadProgress.numberRemaining = tasksInProgress.count + tasksPending.count + queue.count
}
static let badRedirectStrings = ["solutionip", "lodgenet", "monzoon", "landingpage", "btopenzone", "register", "login", "authentic"]
func urlStringIsDisallowedRedirect(_ urlString: String) -> Bool {
// Hotels and similar often do permanent redirects. We can catch some of those.
let s = urlString.lowercased()
for oneBadString in Self.badRedirectStrings {
if s.contains(oneBadString) {
return true
}
}
return false
}
func cacheRedirect(_ oldURLString: String, _ newURLString: String) {
if urlStringIsDisallowedRedirect(newURLString) {
return
}
redirectCache[oldURLString] = newURLString
}
func cachedRedirectForURLString(_ urlString: String) -> String? {
// Follow chains of redirects, but avoid loops.
var urlStrings = Set<String>()
urlStrings.insert(urlString)
var currentString = urlString
while(true) {
if let oneRedirectString = redirectCache[currentString] {
if urlStrings.contains(oneRedirectString) {
// Cycle. Bail.
return nil
}
urlStrings.insert(oneRedirectString)
currentString = oneRedirectString
}
else {
break
}
}
return currentString == urlString ? nil : currentString
}
}
// MARK: - DownloadInfo
private final class DownloadInfo {
let identifier: String
let urlRequest: URLRequest
var data: Data?
var error: Error?
var urlResponse: URLResponse?
var canceled = false
var statusCode: Int {
return urlResponse?.forcedStatusCode ?? 0
}
init(_ identifier: String, urlRequest: URLRequest) {
self.identifier = identifier
self.urlRequest = urlRequest
}
func addData(_ d: Data) {
if data == nil {
data = Data()
}
data!.append(d)
}
}

View File

@@ -0,0 +1,46 @@
//
// HTTPConditionalGetInfo.swift
// RSWeb
//
// Created by Brent Simmons on 4/11/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct HTTPConditionalGetInfo: Codable, Equatable, Sendable {
public let lastModified: String?
public let etag: String?
public init?(lastModified: String?, etag: String?) {
if lastModified == nil && etag == nil {
return nil
}
self.lastModified = lastModified
self.etag = etag
}
public init?(urlResponse: HTTPURLResponse) {
let lastModified = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.lastModified)
let etag = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.etag)
self.init(lastModified: lastModified, etag: etag)
}
public init?(headers: [AnyHashable : Any]) {
let lastModified = headers[HTTPResponseHeader.lastModified] as? String
let etag = headers[HTTPResponseHeader.etag] as? String
self.init(lastModified: lastModified, etag: etag)
}
public func addRequestHeadersToURLRequest(_ urlRequest: inout URLRequest) {
// Bug seen in the wild: lastModified with last possible 32-bit date, which is in 2038. Ignore those.
// TODO: drop this check in late 2037.
if let lastModified = lastModified, !lastModified.contains("2038") {
urlRequest.setValue(lastModified, forHTTPHeaderField: HTTPRequestHeader.ifModifiedSince)
}
if let etag = etag {
urlRequest.setValue(etag, forHTTPHeaderField: HTTPRequestHeader.ifNoneMatch)
}
}
}

View File

@@ -0,0 +1,29 @@
//
// HTTPDateInfo.swift
// RSWeb
//
// Created by Maurice Parker on 5/12/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
public struct HTTPDateInfo: Codable, Equatable {
private static let formatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEEE, dd LLL yyyy HH:mm:ss zzz"
return dateFormatter
}()
public let date: Date?
public init?(urlResponse: HTTPURLResponse) {
if let headerDate = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.date) {
date = HTTPDateInfo.formatter.date(from: headerDate)
} else {
date = nil
}
}
}

View File

@@ -0,0 +1,39 @@
//
// HTTPLinkPagingInfo.swift
// RSWeb
//
// Created by Maurice Parker on 5/12/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
public struct HTTPLinkPagingInfo {
public let nextPage: String?
public let lastPage: String?
public init(nextPage: String?, lastPage: String?) {
self.nextPage = nextPage
self.lastPage = lastPage
}
public init(urlResponse: HTTPURLResponse) {
guard let linkHeader = urlResponse.valueForHTTPHeaderField(HTTPResponseHeader.link) else {
self.init(nextPage: nil, lastPage: nil)
return
}
let links = linkHeader.components(separatedBy: ",")
var dict: [String: String] = [:]
for link in links {
let components = link.components(separatedBy:"; ")
let page = components[0].trimmingCharacters(in: CharacterSet(charactersIn: " <>"))
dict[components[1]] = page
}
self.init(nextPage: dict["rel=\"next\""], lastPage: dict["rel=\"last\""])
}
}

View File

@@ -0,0 +1,18 @@
//
// HTTPMethod.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct HTTPMethod {
public static let get = "GET"
public static let post = "POST"
public static let put = "PUT"
public static let patch = "PATCH"
public static let delete = "DELETE"
}

View File

@@ -0,0 +1,22 @@
//
// HTTPRequestHeader.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct HTTPRequestHeader {
public static let userAgent = "User-Agent"
public static let authorization = "Authorization"
public static let contentType = "Content-Type"
public static let acceptType = "Accept-Type"
// Conditional GET
public static let ifModifiedSince = "If-Modified-Since"
public static let ifNoneMatch = "If-None-Match" //Etag
}

View File

@@ -0,0 +1,61 @@
//
// HTTPResponseCode.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct HTTPResponseCode {
// http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
// Not an enum because the main interest is the actual values.
public static let responseContinue = 100 //"continue" is a language keyword, hence the weird name
public static let switchingProtocols = 101
public static let OK = 200
public static let created = 201
public static let accepted = 202
public static let nonAuthoritativeInformation = 203
public static let noContent = 204
public static let resetContent = 205
public static let partialContent = 206
public static let redirectMultipleChoices = 300
public static let redirectPermanent = 301
public static let redirectTemporary = 302
public static let redirectSeeOther = 303
public static let notModified = 304
public static let useProxy = 305
public static let unused = 306
public static let redirectVeryTemporary = 307
public static let badRequest = 400
public static let unauthorized = 401
public static let paymentRequired = 402
public static let forbidden = 403
public static let notFound = 404
public static let methodNotAllowed = 405
public static let notAcceptable = 406
public static let proxyAuthenticationRequired = 407
public static let requestTimeout = 408
public static let conflict = 409
public static let gone = 410
public static let lengthRequired = 411
public static let preconditionFailed = 412
public static let entityTooLarge = 413
public static let URITooLong = 414
public static let unsupportedMediaType = 415
public static let requestedRangeNotSatisfiable = 416
public static let expectationFailed = 417
public static let internalServerError = 500
public static let notImplemented = 501
public static let badGateway = 502
public static let serviceUnavailable = 503
public static let gatewayTimeout = 504
public static let HTTPVersionNotSupported = 505
}

View File

@@ -0,0 +1,25 @@
//
// HTTPResponseHeader.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct HTTPResponseHeader {
public static let contentType = "Content-Type"
public static let location = "Location"
public static let link = "Links"
public static let date = "Date"
// Conditional GET. See:
// http://fishbowl.pastiche.org/2002/10/21/http_conditional_get_for_rss_hackers/
public static let lastModified = "Last-Modified"
// Changed to the canonical case for lookups against a case sensitive dictionary
// https://developer.apple.com/documentation/foundation/httpurlresponse/1417930-allheaderfields
public static let etag = "Etag"
}

View File

@@ -0,0 +1,148 @@
//
// MacWebBrowser.swift
// RSWeb
//
// Created by Brent Simmons on 12/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
#if os(macOS)
import AppKit
import UniformTypeIdentifiers
@MainActor public class MacWebBrowser {
/// Opens a URL in the default browser.
@discardableResult public class func openURL(_ url: URL, inBackground: Bool = false) -> Bool {
// TODO: make this function async
guard let preparedURL = url.preparedForOpeningInBrowser() else {
return false
}
if (inBackground) {
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = false
NSWorkspace.shared.open(url, configuration: configuration, completionHandler: nil)
return true
}
return NSWorkspace.shared.open(preparedURL)
}
/// Returns an array of the browsers installed on the system, sorted by name.
///
/// "Browsers" are applications that can both handle `https` URLs, and display HTML documents.
public class func sortedBrowsers() -> [MacWebBrowser] {
let httpsAppURLs = NSWorkspace.shared.urlsForApplications(toOpen: URL(string: "https://apple.com/")!)
let htmlAppURLs = NSWorkspace.shared.urlsForApplications(toOpen: UTType.html)
let browserAppURLs = Set(httpsAppURLs).intersection(Set(htmlAppURLs))
return browserAppURLs.compactMap { MacWebBrowser(url: $0) }.sorted {
if let leftName = $0.name, let rightName = $1.name {
return leftName < rightName
}
return false
}
}
/// The filesystem URL of the default web browser.
private class var defaultBrowserURL: URL? {
return NSWorkspace.shared.urlForApplication(toOpen: URL(string: "https:///")!)
}
/// The user's default web browser.
public class var `default`: MacWebBrowser {
return MacWebBrowser(url: defaultBrowserURL!)
}
/// The filesystem URL of the web browser.
public let url: URL
private lazy var _icon: NSImage? = {
if let values = try? url.resourceValues(forKeys: [.effectiveIconKey]) {
return values.effectiveIcon as? NSImage
}
return nil
}()
/// The application icon of the web browser.
public var icon: NSImage? {
return _icon
}
private lazy var _name: String? = {
if let values = try? url.resourceValues(forKeys: [.localizedNameKey]), var name = values.localizedName {
if let extensionRange = name.range(of: ".app", options: [.anchored, .backwards]) {
name = name.replacingCharacters(in: extensionRange, with: "")
}
return name
}
return nil
}()
/// The localized name of the web browser, with any `.app` extension removed.
public var name: String? {
return _name
}
private lazy var _bundleIdentifier: String? = {
return Bundle(url: url)?.bundleIdentifier
}()
/// The bundle identifier of the web browser.
public var bundleIdentifier: String? {
return _bundleIdentifier
}
/// Initializes a `MacWebBrowser` with a URL on disk.
/// - Parameter url: The filesystem URL of the browser.
public init(url: URL) {
self.url = url
}
/// Initializes a `MacWebBrowser` from a bundle identifier.
/// - Parameter bundleIdentifier: The bundle identifier of the browser.
public convenience init?(bundleIdentifier: String) {
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) else {
return nil
}
self.init(url: url)
}
/// Opens a URL in this browser.
/// - Parameters:
/// - url: The URL to open.
/// - inBackground: If `true`, attempt to load the URL without bringing the browser to the foreground.
@discardableResult public func openURL(_ url: URL, inBackground: Bool = false) -> Bool {
// TODO: make this function async.
guard let preparedURL = url.preparedForOpeningInBrowser() else {
return false
}
Task { @MainActor in
let configuration = NSWorkspace.OpenConfiguration()
if inBackground {
configuration.activates = false
}
NSWorkspace.shared.open([preparedURL], withApplicationAt: self.url, configuration: configuration, completionHandler: nil)
}
return true
}
}
#endif

View File

@@ -0,0 +1,57 @@
//
// MimeType.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct MimeType {
// This could certainly use expansion.
public static let png = "image/png"
public static let jpeg = "image/jpeg"
public static let jpg = "image/jpg"
public static let gif = "image/gif"
public static let tiff = "image/tiff"
public static let formURLEncoded = "application/x-www-form-urlencoded"
}
public extension String {
func isMimeTypeImage() -> Bool {
return self.isOfGeneralMimeType("image")
}
func isMimeTypeAudio() -> Bool {
return self.isOfGeneralMimeType("audio")
}
func isMimeTypeVideo() -> Bool {
return self.isOfGeneralMimeType("video")
}
func isMimeTypeTimeBasedMedia() -> Bool {
return self.isMimeTypeAudio() || self.isMimeTypeVideo()
}
private func isOfGeneralMimeType(_ type: String) -> Bool {
let lower = self.lowercased()
if lower.hasPrefix(type) {
return true
}
if lower.hasPrefix("x-\(type)") {
return true
}
return false
}
}

View File

@@ -0,0 +1,168 @@
//
// OneShotDownload.swift
// RSWeb
//
// Created by Brent Simmons on 8/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
import os
public typealias DownloadData = (data: Data?, response: URLResponse?)
public final class OneShotDownloadManager: Sendable {
public static let shared = OneShotDownloadManager()
private let urlSession: URLSession
init() {
let sessionConfiguration = URLSessionConfiguration.ephemeral
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
sessionConfiguration.httpShouldSetCookies = false
sessionConfiguration.httpCookieAcceptPolicy = .never
sessionConfiguration.httpMaximumConnectionsPerHost = 1
sessionConfiguration.httpCookieStorage = nil
sessionConfiguration.urlCache = nil
sessionConfiguration.timeoutIntervalForRequest = 30
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
urlSession = URLSession(configuration: sessionConfiguration)
}
deinit {
urlSession.invalidateAndCancel()
}
func download(_ url: URL) async throws -> DownloadData {
try await withCheckedThrowingContinuation { continuation in
download(url) { data, response, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: (data: data, response: response))
}
}
}
}
public func download(_ urlRequest: URLRequest) async throws -> DownloadData {
try await withCheckedThrowingContinuation { continuation in
download(urlRequest) { data, response, error in
if let error {
continuation.resume(throwing: error)
} else {
continuation.resume(returning: (data: data, response: response))
}
}
}
}
private func download(_ url: URL, _ completion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) {
let task = urlSession.dataTask(with: url, completionHandler: completion)
task.resume()
}
private func download(_ urlRequest: URLRequest, _ completion: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) {
let task = urlSession.dataTask(with: urlRequest) { (data, response, error) in
DispatchQueue.main.async() {
completion(data, response, error)
}
}
task.resume()
}
}
// MARK: - Downloading using a cache
private struct WebCacheRecord {
let url: URL
let dateDownloaded: Date
let data: Data
let response: URLResponse
}
private final class WebCache: Sendable {
private let cache = OSAllocatedUnfairLock(initialState: [URL: WebCacheRecord]())
func cleanup(_ cleanupInterval: TimeInterval) {
cache.withLock { d in
let cutoffDate = Date(timeInterval: -cleanupInterval, since: Date())
for key in d.keys {
let cacheRecord = d[key]!
if shouldDelete(cacheRecord, cutoffDate) {
d[key] = nil
}
}
}
}
private func shouldDelete(_ cacheRecord: WebCacheRecord, _ cutoffDate: Date) -> Bool {
cacheRecord.dateDownloaded < cutoffDate
}
subscript(_ url: URL) -> WebCacheRecord? {
get {
cache.withLock { d in
return d[url]
}
}
set {
cache.withLock { d in
if let cacheRecord = newValue {
d[url] = cacheRecord
}
else {
d[url] = nil
}
}
}
}
}
// URLSessionConfiguration has a cache policy.
// But we dont know how it works, and the unimplemented parts spook us a bit.
// So we use a cache that works exactly as we want it to work.
public final actor DownloadWithCacheManager {
public static let shared = DownloadWithCacheManager()
private let cache = WebCache()
private static let timeToLive: TimeInterval = 10 * 60 // 10 minutes
private static let cleanupInterval: TimeInterval = 5 * 60 // clean up the cache at most every 5 minutes
private var lastCleanupDate = Date()
public func download(_ url: URL, forceRedownload: Bool = false) async throws -> DownloadData {
if lastCleanupDate.timeIntervalSinceNow < -DownloadWithCacheManager.cleanupInterval {
cleanupCache()
}
if !forceRedownload {
if let cacheRecord = cache[url] {
return (cacheRecord.data, cacheRecord.response)
}
}
let downloadData = try await OneShotDownloadManager.shared.download(url)
if let data = downloadData.data, let response = downloadData.response, response.statusIsOK {
let cacheRecord = WebCacheRecord(url: url, dateDownloaded: Date(), data: data, response: response)
cache[url] = cacheRecord
}
return downloadData
}
public func cleanupCache() {
lastCleanupDate = Date()
cache.cleanup(DownloadWithCacheManager.timeToLive)
}
}

View File

@@ -0,0 +1,219 @@
/*
Copyright (c) 2014, Ashley Mills
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGE.
*/
import SystemConfiguration
import Foundation
public enum ReachabilityError: Error {
case failedToCreateWithHostname(String, Int32)
case unableToGetFlags(Int32)
}
public class Reachability {
/// Returns true if the internet is reachable.
///
/// Uses apple.com as an indicator for the internet at large,
/// since its surely one of the easiest-to-reach domain names.
/// Added 6 April 2024. Not part of Ashley Millss original code.
public static var internetIsReachable: Bool {
guard let reachability = try? Reachability(hostname: "apple.com") else {
return false
}
return reachability.connection != .unavailable
}
public typealias NetworkReachable = (Reachability) -> ()
public typealias NetworkUnreachable = (Reachability) -> ()
@available(*, unavailable, renamed: "Connection")
public enum NetworkStatus: CustomStringConvertible {
case notReachable, reachableViaWiFi, reachableViaWWAN
public var description: String {
switch self {
case .reachableViaWWAN: return "Cellular"
case .reachableViaWiFi: return "WiFi"
case .notReachable: return "No Connection"
}
}
}
public enum Connection: CustomStringConvertible {
@available(*, deprecated, renamed: "unavailable")
case none
case unavailable, wifi, cellular
public var description: String {
switch self {
case .cellular: return "Cellular"
case .wifi: return "WiFi"
case .unavailable: return "No Connection"
case .none: return "unavailable"
}
}
}
/// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`)
public var allowsCellularConnection: Bool
public var connection: Connection {
if flags == nil {
try? setReachabilityFlags()
}
switch flags?.connection {
case .unavailable?, nil: return .unavailable
case .none?: return .unavailable
case .cellular?: return allowsCellularConnection ? .cellular : .unavailable
case .wifi?: return .wifi
}
}
fileprivate(set) var notifierRunning = false
fileprivate let reachabilityRef: SCNetworkReachability
fileprivate let reachabilitySerialQueue: DispatchQueue
fileprivate(set) var flags: SCNetworkReachabilityFlags?
required public init(reachabilityRef: SCNetworkReachability,
queueQoS: DispatchQoS = .default,
targetQueue: DispatchQueue? = nil) {
self.allowsCellularConnection = true
self.reachabilityRef = reachabilityRef
self.reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue)
}
public convenience init(hostname: String,
queueQoS: DispatchQoS = .default,
targetQueue: DispatchQueue? = nil) throws {
guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else {
throw ReachabilityError.failedToCreateWithHostname(hostname, SCError())
}
self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue)
}
}
public extension Reachability {
var description: String {
return flags?.description ?? "unavailable flags"
}
}
fileprivate extension Reachability {
func setReachabilityFlags() throws {
try reachabilitySerialQueue.sync { [unowned self] in
var flags = SCNetworkReachabilityFlags()
if !SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags) {
throw ReachabilityError.unableToGetFlags(SCError())
}
self.flags = flags
}
}
}
extension SCNetworkReachabilityFlags {
typealias Connection = Reachability.Connection
var connection: Connection {
guard isReachableFlagSet else { return .unavailable }
// If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi
#if targetEnvironment(simulator)
return .wifi
#else
var connection = Connection.unavailable
if !isConnectionRequiredFlagSet {
connection = .wifi
}
if isConnectionOnTrafficOrDemandFlagSet {
if !isInterventionRequiredFlagSet {
connection = .wifi
}
}
if isOnWWANFlagSet {
connection = .cellular
}
return connection
#endif
}
var isOnWWANFlagSet: Bool {
#if os(iOS)
return contains(.isWWAN)
#else
return false
#endif
}
var isReachableFlagSet: Bool {
return contains(.reachable)
}
var isConnectionRequiredFlagSet: Bool {
return contains(.connectionRequired)
}
var isInterventionRequiredFlagSet: Bool {
return contains(.interventionRequired)
}
var isConnectionOnTrafficFlagSet: Bool {
return contains(.connectionOnTraffic)
}
var isConnectionOnDemandFlagSet: Bool {
return contains(.connectionOnDemand)
}
var isConnectionOnTrafficOrDemandFlagSet: Bool {
return !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty
}
var isTransientConnectionFlagSet: Bool {
return contains(.transientConnection)
}
var isLocalAddressFlagSet: Bool {
return contains(.isLocalAddress)
}
var isDirectFlagSet: Bool {
return contains(.isDirect)
}
var description: String {
let W = isOnWWANFlagSet ? "W" : "-"
let R = isReachableFlagSet ? "R" : "-"
let c = isConnectionRequiredFlagSet ? "c" : "-"
let t = isTransientConnectionFlagSet ? "t" : "-"
let i = isInterventionRequiredFlagSet ? "i" : "-"
let C = isConnectionOnTrafficFlagSet ? "C" : "-"
let D = isConnectionOnDemandFlagSet ? "D" : "-"
let l = isLocalAddressFlagSet ? "l" : "-"
let d = isDirectFlagSet ? "d" : "-"
return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)"
}
}

View File

@@ -0,0 +1,37 @@
//
// NSURL+RSWeb.swift
// RSWeb
//
// Created by Brent Simmons on 12/26/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public extension URL {
func appendingQueryItem(_ queryItem: URLQueryItem) -> URL? {
appendingQueryItems([queryItem])
}
func appendingQueryItems(_ queryItems: [URLQueryItem]) -> URL? {
guard var components = URLComponents(url: self, resolvingAgainstBaseURL: false) else {
return nil
}
var newQueryItems = components.queryItems ?? []
newQueryItems.append(contentsOf: queryItems)
components.queryItems = newQueryItems
return components.url
}
func preparedForOpeningInBrowser() -> URL? {
var urlString = absoluteString.replacingOccurrences(of: " ", with: "%20")
urlString = urlString.replacingOccurrences(of: "^", with: "%5E")
urlString = urlString.replacingOccurrences(of: "&amp;", with: "&")
urlString = urlString.replacingOccurrences(of: "&#38;", with: "&")
return URL(string: urlString)
}
}

View File

@@ -0,0 +1,34 @@
//
// URLComponents.swift
//
//
// Created by Maurice Parker on 11/8/20.
//
import Foundation
public extension URLComponents {
// `+` is a valid character in query component as per RFC 3986 (https://developer.apple.com/documentation/foundation/nsurlcomponents/1407752-queryitems)
// workaround:
// - http://www.openradar.me/24076063
// - https://stackoverflow.com/a/37314144
var enhancedPercentEncodedQuery: String? {
guard !(queryItems?.isEmpty ?? true) else {
return nil
}
var allowedCharacters = CharacterSet.urlQueryAllowed
allowedCharacters.remove(charactersIn: "!*'();:@&=+$,/?%#[]")
var queries = [String]()
for queryItem in queryItems! {
if let value = queryItem.value?.addingPercentEncoding(withAllowedCharacters: allowedCharacters)?.replacingOccurrences(of: "%20", with: "+") {
queries.append("\(queryItem.name)=\(value)")
}
}
return queries.joined(separator: "&")
}
}

View File

@@ -0,0 +1,45 @@
//
// URLResponse+RSWeb.swift
// RSWeb
//
// Created by Brent Simmons on 8/14/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public extension URLResponse {
var statusIsOK: Bool {
return forcedStatusCode >= 200 && forcedStatusCode <= 299
}
var forcedStatusCode: Int {
// Return actual statusCode or 0 if there isnt one.
if let response = self as? HTTPURLResponse {
return response.statusCode
}
return 0
}
}
public extension HTTPURLResponse {
func valueForHTTPHeaderField(_ headerField: String) -> String? {
// Case-insensitive. HTTP headers may not be in the case you expect.
let lowerHeaderField = headerField.lowercased()
for (key, value) in allHeaderFields {
if lowerHeaderField == (key as? String)?.lowercased() {
return value as? String
}
}
return nil
}
}

View File

@@ -0,0 +1,16 @@
//
// URLScheme.swift
//
//
// Created by Brent Simmons on 6/30/24.
//
import Foundation
public struct URLScheme {
public static let http = "http"
public static let https = "https"
public static let mailto = "mailto"
public static let tel = "tel"
}

View File

@@ -0,0 +1,23 @@
//
// UserAgent.swift
// RSWeb
//
// Created by Brent Simmons on 8/27/16.
// Copyright © 2016 Ranchero Software, LLC. All rights reserved.
//
import Foundation
public struct UserAgent {
public static let fromInfoPlist: String = {
Bundle.main.object(forInfoDictionaryKey: "UserAgent") as! String
}()
public static let headers: [String: String] = {
let userAgent = fromInfoPlist
return [HTTPRequestHeader.userAgent: userAgent]
}()
}

View File

@@ -0,0 +1,288 @@
//
// Transport.swift
// RSWeb
//
// Created by Maurice Parker on 5/4/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
// Inspired by: http://robnapier.net/a-mockery-of-protocols
import Foundation
public enum TransportError: LocalizedError {
case noData
case noURL
case suspended
case httpError(status: Int)
public var errorDescription: String? {
switch self {
case .httpError(let status):
switch status {
case 400:
return NSLocalizedString("Bad Request", comment: "Bad Request")
case 401:
return NSLocalizedString("Unauthorized", comment: "Unauthorized")
case 402:
return NSLocalizedString("Payment Required", comment: "Payment Required")
case 403:
return NSLocalizedString("Forbidden", comment: "Forbidden")
case 404:
return NSLocalizedString("Not Found", comment: "Not Found")
case 405:
return NSLocalizedString("Method Not Allowed", comment: "Method Not Allowed")
case 406:
return NSLocalizedString("Not Acceptable", comment: "Not Acceptable")
case 407:
return NSLocalizedString("Proxy Authentication Required", comment: "Proxy Authentication Required")
case 408:
return NSLocalizedString("Request Timeout", comment: "Request Timeout")
case 409:
return NSLocalizedString("Conflict", comment: "Conflict")
case 410:
return NSLocalizedString("Gone", comment: "Gone")
case 411:
return NSLocalizedString("Length Required", comment: "Length Required")
case 412:
return NSLocalizedString("Precondition Failed", comment: "Precondition Failed")
case 413:
return NSLocalizedString("Payload Too Large", comment: "Payload Too Large")
case 414:
return NSLocalizedString("Request-URI Too Long", comment: "Request-URI Too Long")
case 415:
return NSLocalizedString("Unsupported Media Type", comment: "Unsupported Media Type")
case 416:
return NSLocalizedString("Requested Range Not Satisfiable", comment: "Requested Range Not Satisfiable")
case 417:
return NSLocalizedString("Expectation Failed", comment: "Expectation Failed")
case 418:
return NSLocalizedString("I'm a teapot", comment: "I'm a teapot")
case 421:
return NSLocalizedString("Misdirected Request", comment: "Misdirected Request")
case 422:
return NSLocalizedString("Unprocessable Entity", comment: "Unprocessable Entity")
case 423:
return NSLocalizedString("Locked", comment: "Locked")
case 424:
return NSLocalizedString("Failed Dependency", comment: "Failed Dependency")
case 426:
return NSLocalizedString("Upgrade Required", comment: "Upgrade Required")
case 428:
return NSLocalizedString("Precondition Required", comment: "Precondition Required")
case 429:
return NSLocalizedString("Too Many Requests", comment: "Too Many Requests")
case 431:
return NSLocalizedString("Request Header Fields Too Large", comment: "Request Header Fields Too Large")
case 444:
return NSLocalizedString("Connection Closed Without Response", comment: "Connection Closed Without Response")
case 451:
return NSLocalizedString("Unavailable For Legal Reasons", comment: "Unavailable For Legal Reasons")
case 499:
return NSLocalizedString("Client Closed Request", comment: "Client Closed Request")
case 500:
return NSLocalizedString("Internal Server Error", comment: "Internal Server Error")
case 501:
return NSLocalizedString("Not Implemented", comment: "Not Implemented")
case 502:
return NSLocalizedString("Bad Gateway", comment: "Bad Gateway")
case 503:
return NSLocalizedString("Service Unavailable", comment: "Service Unavailable")
case 504:
return NSLocalizedString("Gateway Timeout", comment: "Gateway Timeout")
case 505:
return NSLocalizedString("HTTP Version Not Supported", comment: "HTTP Version Not Supported")
case 506:
return NSLocalizedString("Variant Also Negotiates", comment: "Variant Also Negotiates")
case 507:
return NSLocalizedString("Insufficient Storage", comment: "Insufficient Storage")
case 508:
return NSLocalizedString("Loop Detected", comment: "Loop Detected")
case 510:
return NSLocalizedString("Not Extended", comment: "Not Extended")
case 511:
return NSLocalizedString("Network Authentication Required", comment: "Network Authentication Required")
case 599:
return NSLocalizedString("Network Connect Timeout Error", comment: "Network Connect Timeout Error")
default:
let msg = NSLocalizedString("HTTP Status: ", comment: "Unexpected error")
return "\(msg) \(status)"
}
default:
return NSLocalizedString("An unknown network error occurred.", comment: "Unknown error")
}
}
}
public protocol Transport: Sendable {
/// Cancels all pending requests
func cancelAll()
/// Sends URLRequest and returns the HTTP headers and the data payload.
@discardableResult
func send(request: URLRequest) async throws -> (HTTPURLResponse, Data?)
func send(request: URLRequest, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void)
/// Sends URLRequest that doesn't require any result information.
func send(request: URLRequest, method: String) async throws
func send(request: URLRequest, method: String, completion: @escaping @Sendable (Result<Void, Error>) -> Void)
/// Sends URLRequest with a data payload and returns the HTTP headers and the data payload.
@discardableResult
func send(request: URLRequest, method: String, payload: Data) async throws -> (HTTPURLResponse, Data?)
func send(request: URLRequest, method: String, payload: Data, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void)
}
extension URLSession: Transport {
public func cancelAll() {
getTasksWithCompletionHandler { dataTasks, uploadTasks, downloadTasks in
for dataTask in dataTasks {
dataTask.cancel()
}
for uploadTask in uploadTasks {
uploadTask.cancel()
}
for downloadTask in downloadTasks {
downloadTask.cancel()
}
}
}
public func send(request: URLRequest) async throws -> (HTTPURLResponse, Data?) {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request) { result in
switch result {
case .success(let (response, data)):
continuation.resume(returning: (response, data))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
public func send(request: URLRequest, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
let task = self.dataTask(with: request) { (data, response, error) in
DispatchQueue.main.async {
if let error = error {
return completion(.failure(error))
}
guard let response = response as? HTTPURLResponse, let data = data else {
return completion(.failure(TransportError.noData))
}
switch response.forcedStatusCode {
case 200...399:
completion(.success((response, data)))
default:
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
}
}
}
task.resume()
}
public func send(request: URLRequest, method: String) async throws {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, method: method) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
public func send(request: URLRequest, method: String, completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
var sendRequest = request
sendRequest.httpMethod = method
let task = self.dataTask(with: sendRequest) { (data, response, error) in
DispatchQueue.main.async {
if let error = error {
return completion(.failure(error))
}
guard let response = response as? HTTPURLResponse else {
return completion(.failure(TransportError.noData))
}
switch response.forcedStatusCode {
case 200...399:
completion(.success(()))
default:
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
}
}
}
task.resume()
}
public func send(request: URLRequest, method: String, payload: Data) async throws -> (HTTPURLResponse, Data?) {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, method: method, payload: payload) { result in
switch result {
case .success(let (response, data)):
continuation.resume(returning: (response, data))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
public func send(request: URLRequest, method: String, payload: Data, completion: @escaping @Sendable (Result<(HTTPURLResponse, Data?), Error>) -> Void) {
var sendRequest = request
sendRequest.httpMethod = method
let task = self.uploadTask(with: sendRequest, from: payload) { (data, response, error) in
DispatchQueue.main.async {
if let error = error {
return completion(.failure(error))
}
guard let response = response as? HTTPURLResponse, let data = data else {
return completion(.failure(TransportError.noData))
}
switch response.forcedStatusCode {
case 200...399:
completion(.success((response, data)))
default:
completion(.failure(TransportError.httpError(status: response.forcedStatusCode)))
}
}
}
task.resume()
}
public static func webserviceTransport() -> Transport {
let sessionConfiguration = URLSessionConfiguration.default
sessionConfiguration.requestCachePolicy = .reloadIgnoringLocalCacheData
sessionConfiguration.timeoutIntervalForRequest = 60.0
sessionConfiguration.httpShouldSetCookies = false
sessionConfiguration.httpCookieAcceptPolicy = .never
sessionConfiguration.httpMaximumConnectionsPerHost = 2
sessionConfiguration.httpCookieStorage = nil
sessionConfiguration.urlCache = nil
sessionConfiguration.httpAdditionalHeaders = UserAgent.headers
return URLSession(configuration: sessionConfiguration)
}
}

View File

@@ -0,0 +1,198 @@
//
// JSONTransport.swift
// RSWeb
//
// Created by Maurice Parker on 5/6/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
extension Transport {
public func send<R: Decodable & Sendable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { result in
switch result {
case .success(let (response, decoded)):
continuation.resume(returning: (response, decoded))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/**
Sends an HTTP get and returns JSON object(s)
*/
public func send<R: Decodable & Sendable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping @Sendable (Result<(HTTPURLResponse, R?), Error>) -> Void) {
send(request: request) { result in
DispatchQueue.main.async {
switch result {
case .success(let (response, data)):
if let data = data, !data.isEmpty {
// PBS 27 Sep. 2019: decode the JSON on a background thread.
// The profiler says that this is 45% of whats happening on the main thread
// during an initial sync with Feedbin.
DispatchQueue.global(qos: .background).async {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
decoder.keyDecodingStrategy = keyDecoding
do {
let decoded = try decoder.decode(R.self, from: data)
DispatchQueue.main.async {
completion(.success((response, decoded)))
}
}
catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
}
else {
completion(.success((response, nil)))
}
case .failure(let error):
completion(.failure(error))
}
}
}
}
public func send<P: Encodable & Sendable>(request: URLRequest, method: String, payload: P) async throws {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, method: method, payload: payload) { result in
switch result {
case .success:
continuation.resume()
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/**
Sends the specified HTTP method with a JSON payload.
*/
public func send<P: Encodable>(request: URLRequest, method: String, payload: P, completion: @escaping @Sendable (Result<Void, Error>) -> Void) {
var postRequest = request
postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
let data: Data
do {
data = try JSONEncoder().encode(payload)
} catch {
completion(.failure(error))
return
}
send(request: postRequest, method: method, payload: data) { result in
DispatchQueue.main.async {
switch result {
case .success((_, _)):
completion(.success(()))
case .failure(let error):
completion(.failure(error))
}
}
}
}
/**
Sends the specified HTTP method with a JSON payload and returns JSON object(s).
*/
public func send<P: Encodable, R: Decodable>(request: URLRequest, method: String, payload: P, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping @Sendable (Result<(HTTPURLResponse, R?), Error>) -> Void) {
var postRequest = request
postRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: HTTPRequestHeader.contentType)
let data: Data
do {
data = try JSONEncoder().encode(payload)
} catch {
completion(.failure(error))
return
}
send(request: postRequest, method: method, payload: data) { result in
DispatchQueue.main.async {
switch result {
case .success(let (response, data)):
do {
if let data = data, !data.isEmpty {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
decoder.keyDecodingStrategy = keyDecoding
let decoded = try decoder.decode(R.self, from: data)
completion(.success((response, decoded)))
} else {
completion(.success((response, nil)))
}
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
}
public func send<R: Decodable>(request: URLRequest, method: String, data: Data, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys) async throws -> (HTTPURLResponse, R?) {
try await withCheckedThrowingContinuation { continuation in
self.send(request: request, method: method, data: data, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { result in
switch result {
case .success(let (response, decoded)):
continuation.resume(returning: (response, decoded))
case .failure(let error):
continuation.resume(throwing: error)
}
}
}
}
/**
Sends the specified HTTP method with a Raw payload and returns JSON object(s).
*/
public func send<R: Decodable>(request: URLRequest, method: String, data: Data, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping @Sendable (Result<(HTTPURLResponse, R?), Error>) -> Void) {
send(request: request, method: method, payload: data) { result in
DispatchQueue.main.async {
switch result {
case .success(let (response, data)):
do {
if let data = data, !data.isEmpty {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = dateDecoding
decoder.keyDecodingStrategy = keyDecoding
let decoded = try decoder.decode(R.self, from: data)
completion(.success((response, decoded)))
} else {
completion(.success((response, nil)))
}
} catch {
completion(.failure(error))
}
case .failure(let error):
completion(.failure(error))
}
}
}
}
}