mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Move local modules into a folder named Modules.
This commit is contained in:
27
Modules/Web/Sources/Web/Dictionary+Web.swift
Normal file
27
Modules/Web/Sources/Web/Dictionary+Web.swift
Normal 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¶m2=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
|
||||
}
|
||||
}
|
||||
32
Modules/Web/Sources/Web/DownloadObject.swift
Executable file
32
Modules/Web/Sources/Web/DownloadObject.swift
Executable 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
|
||||
}
|
||||
}
|
||||
|
||||
108
Modules/Web/Sources/Web/DownloadProgress.swift
Executable file
108
Modules/Web/Sources/Web/DownloadProgress.swift
Executable 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
349
Modules/Web/Sources/Web/DownloadSession.swift
Executable file
349
Modules/Web/Sources/Web/DownloadSession.swift
Executable 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)
|
||||
}
|
||||
}
|
||||
46
Modules/Web/Sources/Web/HTTPConditionalGetInfo.swift
Executable file
46
Modules/Web/Sources/Web/HTTPConditionalGetInfo.swift
Executable 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Modules/Web/Sources/Web/HTTPDateInfo.swift
Normal file
29
Modules/Web/Sources/Web/HTTPDateInfo.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
39
Modules/Web/Sources/Web/HTTPLinkPagingInfo.swift
Normal file
39
Modules/Web/Sources/Web/HTTPLinkPagingInfo.swift
Normal 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\""])
|
||||
}
|
||||
}
|
||||
18
Modules/Web/Sources/Web/HTTPMethod.swift
Executable file
18
Modules/Web/Sources/Web/HTTPMethod.swift
Executable 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"
|
||||
}
|
||||
22
Modules/Web/Sources/Web/HTTPRequestHeader.swift
Executable file
22
Modules/Web/Sources/Web/HTTPRequestHeader.swift
Executable 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
|
||||
}
|
||||
61
Modules/Web/Sources/Web/HTTPResponseCode.swift
Executable file
61
Modules/Web/Sources/Web/HTTPResponseCode.swift
Executable 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
|
||||
}
|
||||
25
Modules/Web/Sources/Web/HTTPResponseHeader.swift
Executable file
25
Modules/Web/Sources/Web/HTTPResponseHeader.swift
Executable 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"
|
||||
}
|
||||
148
Modules/Web/Sources/Web/MacWebBrowser.swift
Executable file
148
Modules/Web/Sources/Web/MacWebBrowser.swift
Executable 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
|
||||
57
Modules/Web/Sources/Web/MimeType.swift
Executable file
57
Modules/Web/Sources/Web/MimeType.swift
Executable 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
|
||||
}
|
||||
}
|
||||
168
Modules/Web/Sources/Web/OneShotDownload.swift
Executable file
168
Modules/Web/Sources/Web/OneShotDownload.swift
Executable 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 don’t 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)
|
||||
}
|
||||
}
|
||||
219
Modules/Web/Sources/Web/Reachability.swift
Normal file
219
Modules/Web/Sources/Web/Reachability.swift
Normal 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 it’s surely one of the easiest-to-reach domain names.
|
||||
/// Added 6 April 2024. Not part of Ashley Mills’s 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)"
|
||||
}
|
||||
}
|
||||
37
Modules/Web/Sources/Web/URL+RSWeb.swift
Executable file
37
Modules/Web/Sources/Web/URL+RSWeb.swift
Executable 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: "&", with: "&")
|
||||
urlString = urlString.replacingOccurrences(of: "&", with: "&")
|
||||
|
||||
return URL(string: urlString)
|
||||
}
|
||||
}
|
||||
34
Modules/Web/Sources/Web/URLComponents+RSWeb.swift
Normal file
34
Modules/Web/Sources/Web/URLComponents+RSWeb.swift
Normal 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: "&")
|
||||
}
|
||||
|
||||
}
|
||||
45
Modules/Web/Sources/Web/URLResponse+RSWeb.swift
Executable file
45
Modules/Web/Sources/Web/URLResponse+RSWeb.swift
Executable 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 isn’t 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
|
||||
}
|
||||
}
|
||||
16
Modules/Web/Sources/Web/URLScheme.swift
Normal file
16
Modules/Web/Sources/Web/URLScheme.swift
Normal 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"
|
||||
}
|
||||
23
Modules/Web/Sources/Web/UserAgent.swift
Executable file
23
Modules/Web/Sources/Web/UserAgent.swift
Executable 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]
|
||||
}()
|
||||
}
|
||||
288
Modules/Web/Sources/Web/WebServices/Transport.swift
Normal file
288
Modules/Web/Sources/Web/WebServices/Transport.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
198
Modules/Web/Sources/Web/WebServices/TransportJSON.swift
Normal file
198
Modules/Web/Sources/Web/WebServices/TransportJSON.swift
Normal 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 what’s 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user