Files
OAuthSwift/Sources/OAuthSwiftHTTPRequest.swift
2024-04-16 14:21:34 +02:00

499 lines
22 KiB
Swift

//
// OAuthSwiftHTTPRequest.swift
// OAuthSwift
//
// Created by Dongri Jin on 6/21/14.
// Copyright (c) 2014 Dongri Jin. All rights reserved.
//
import Foundation
#if os(iOS) || os(visionOS)
#if !OAUTH_APP_EXTENSIONS
import UIKit
#endif
#endif
let kHTTPHeaderContentType = "Content-Type"
open class OAuthSwiftHTTPRequest: NSObject, OAuthSwiftRequestHandle {
// Using NSLock for Linux compatible locking
let requestLock = NSLock()
public typealias CompletionHandler = (_ result: Result<OAuthSwiftResponse, OAuthSwiftError>) -> Void
/// HTTP request method
/// https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol#Request_methods
public enum Method: String {
case GET, POST, PUT, DELETE, PATCH, HEAD // , OPTIONS, TRACE, CONNECT
var isBody: Bool {
return self == .POST || self == .PUT || self == .PATCH
}
}
/// Where the additional parameters will be injected
@objc public enum ParamsLocation: Int {
case authorizationHeader, /*FormEncodedBody,*/ requestURIQuery
}
public var config: Config
private var request: URLRequest?
private var task: URLSessionTask?
private var session: URLSession!
fileprivate var cancelRequested = false
public static var executionContext: (@escaping () -> Void) -> Void = { block in
return DispatchQueue.main.async(execute: block)
}
// MARK: INIT
convenience init(url: URL, method: Method = .GET, parameters: OAuthSwift.Parameters = [:], paramsLocation: ParamsLocation = .authorizationHeader, httpBody: Data? = nil, headers: OAuthSwift.Headers = [:], sessionFactory: URLSessionFactory = .default) {
self.init(config: Config(url: url, httpMethod: method, httpBody: httpBody, headers: headers, parameters: parameters, paramsLocation: paramsLocation, sessionFactory: sessionFactory))
}
convenience init(request: URLRequest, paramsLocation: ParamsLocation = .authorizationHeader, sessionFactory: URLSessionFactory = .default) {
self.init(config: Config(urlRequest: request, paramsLocation: paramsLocation, sessionFactory: sessionFactory))
}
init(config: Config) {
self.config = config
}
/// START request
func start(completionHandler completion: CompletionHandler?) {
guard request == nil else { return } // Don't start the same request twice!
do {
self.request = try self.makeRequest()
} catch let error as NSError {
completion?(.failure(.requestCreation(message: error.localizedDescription)))
self.request = nil
return
}
OAuthSwiftHTTPRequest.executionContext {
// perform lock here to prevent cancel calls on another thread while creating the request
self.requestLock.lock()
defer { self.requestLock.unlock() }
if self.cancelRequested {
completion?(.failure(.cancelled))
return
}
self.session = self.config.sessionFactory.build()
let usedRequest = self.request!
if self.config.sessionFactory.useDataTaskClosure {
let completionHandler: (Data?, URLResponse?, Error?) -> Void = { data, resp, error in
OAuthSwiftHTTPRequest.completionHandler(completionHandler: completion,
request: usedRequest,
data: data,
resp: resp,
error: error)
}
self.task = self.session.dataTask(with: usedRequest, completionHandler: completionHandler)
} else {
self.task = self.session.dataTask(with: usedRequest)
}
self.task?.resume()
self.session.finishTasksAndInvalidate()
}
}
/// Function called when receiving data from server.
public static func completionHandler(completionHandler completion: CompletionHandler?, request: URLRequest, data: Data?, resp: URLResponse?, error: Error?) {
// MARK: failure error returned by server
if let error = error {
var oauthError: OAuthSwiftError = .requestError(error: error, request: request)
let nsError = error as NSError
if nsError.code == NSURLErrorCancelled {
oauthError = .cancelled
} else if nsError.isExpiredToken {
oauthError = .tokenExpired(error: error)
}
completion?(.failure(oauthError))
return
}
// MARK: failure no response or data returned by server
guard let response = resp as? HTTPURLResponse, let responseData = data else {
let badRequestCode = 400
let localizedDescription = OAuthSwiftHTTPRequest.descriptionForHTTPStatus(badRequestCode, responseString: "")
var userInfo: [String: Any] = [
NSLocalizedDescriptionKey: localizedDescription
]
if let response = resp { // there is only no data
userInfo[OAuthSwiftError.ResponseKey] = response
}
if let response = resp as? HTTPURLResponse {
userInfo["Response-Headers"] = response.allHeaderFields
}
let error = NSError(domain: OAuthSwiftError.Domain, code: badRequestCode, userInfo: userInfo)
completion?(.failure(.requestError(error: error, request: request)))
return
}
// MARK: failure code > 400
guard response.statusCode < 400 else {
var localizedDescription = ""
let responseString = String(data: responseData, encoding: OAuthSwiftDataEncoding)
// Try to get error information from data as json
let responseJSON = try? JSONSerialization.jsonObject(with: responseData, options: .mutableContainers)
var errorCode: String?
if let responseJSON = responseJSON as? OAuthSwift.Parameters {
if let code = responseJSON["error"] as? String {
errorCode = code
if let description = responseJSON["error_description"] as? String {
localizedDescription = NSLocalizedString("\(code) \(description)", comment: "")
} else {
localizedDescription = NSLocalizedString("\(code)", comment: "")
}
}
} else {
localizedDescription = OAuthSwiftHTTPRequest.descriptionForHTTPStatus(response.statusCode, responseString: String(data: responseData, encoding: OAuthSwiftDataEncoding)!)
}
var userInfo: [String: Any] = [
NSLocalizedDescriptionKey: localizedDescription,
"Response-Headers": response.allHeaderFields,
OAuthSwiftError.ResponseKey: response,
OAuthSwiftError.ResponseDataKey: responseData
]
if let string = responseString {
userInfo["Response-Body"] = string
}
if let urlString = response.url?.absoluteString {
userInfo[NSURLErrorFailingURLErrorKey] = urlString
}
let error = NSError(domain: OAuthSwiftError.Domain, code: response.statusCode, userInfo: userInfo)
if error.isExpiredToken {
completion?(.failure(.tokenExpired(error: error)))
} else if errorCode == "authorization_pending" {
completion?(.failure(.authorizationPending(error: error, request: request)))
} else if errorCode == "slow_down" {
completion?(.failure(.slowDown(error: error, request: request)))
} else if errorCode == "access_denied" {
completion?(.failure(.accessDenied(error: error, request: request)))
} else {
completion?(.failure(.requestError(error: error, request: request)))
}
return
}
// MARK: success
completion?(.success(OAuthSwiftResponse(data: responseData, response: response, request: request)))
}
open func cancel() {
// perform lock here to prevent cancel calls on another thread while creating the request
requestLock.lock()
defer { requestLock.unlock() }
// either cancel the request if it's already running or set the flag to prohibit creation of the request
if let task = task {
task.cancel()
} else {
cancelRequested = true
}
}
open func makeRequest() throws -> URLRequest {
return try OAuthSwiftHTTPRequest.makeRequest(config: self.config)
}
open class func makeRequest(config: Config) throws -> URLRequest {
var request = config.urlRequest
OAuthSwift.log?.trace("URLRequest is created: \(request)")
return try setupRequestForOAuth(request: &request,
parameters: config.parameters,
dataEncoding: config.dataEncoding,
paramsLocation: config.paramsLocation
)
}
open class func makeRequest(
url: Foundation.URL,
method: Method,
headers: OAuthSwift.Headers,
parameters: OAuthSwift.Parameters,
dataEncoding: String.Encoding,
body: Data? = nil,
paramsLocation: ParamsLocation = .authorizationHeader) throws -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
OAuthSwift.log?.trace("URLRequest is created: \(request)")
return try setupRequestForOAuth(
request: &request,
parameters: parameters,
dataEncoding: dataEncoding,
body: body,
paramsLocation: paramsLocation
)
}
open class func setupRequestForOAuth(
request: inout URLRequest,
parameters: OAuthSwift.Parameters,
dataEncoding: String.Encoding = OAuthSwiftDataEncoding,
body: Data? = nil,
paramsLocation: ParamsLocation = .authorizationHeader) throws -> URLRequest {
let finalParameters: OAuthSwift.Parameters
switch paramsLocation {
case .authorizationHeader:
finalParameters = parameters.filter { key, _ in !key.hasPrefix("oauth_") }
case .requestURIQuery:
finalParameters = parameters
}
if let b = body {
request.httpBody = b
} else {
if !finalParameters.isEmpty {
let charset = dataEncoding.charset
let headers = request.allHTTPHeaderFields ?? [:]
if request.httpMethod == "GET" || request.httpMethod == "HEAD" || request.httpMethod == "DELETE" {
let queryString = finalParameters.urlEncodedQuery
let url = request.url!
request.url = url.urlByAppending(queryString: queryString)
if headers[kHTTPHeaderContentType] == nil {
request.setValue("application/x-www-form-urlencoded; charset=\(charset)", forHTTPHeaderField: kHTTPHeaderContentType)
}
} else {
if let contentType = headers[kHTTPHeaderContentType], contentType.contains("application/json") {
let jsonData = try JSONSerialization.data(withJSONObject: finalParameters, options: [])
request.setValue("application/json; charset=\(charset)", forHTTPHeaderField: kHTTPHeaderContentType)
request.httpBody = jsonData
} else if let contentType = headers[kHTTPHeaderContentType], contentType.contains("multipart/form-data") {
// snip
} else {
request.setValue("application/x-www-form-urlencoded; charset=\(charset)", forHTTPHeaderField: kHTTPHeaderContentType)
let queryString = finalParameters.urlEncodedQuery
request.httpBody = queryString.data(using: dataEncoding, allowLossyConversion: true)
}
}
}
}
return request
}
}
// MARK: - Request configuraiton
extension OAuthSwiftHTTPRequest {
/// Configuration for request
public struct Config {
/// URLRequest (url, method, ...)
public var urlRequest: URLRequest
/// These parameters are either added to the query string for GET, HEAD and DELETE requests or
/// used as the http body in case of POST, PUT or PATCH requests.
///
/// If used in the body they are either encoded as JSON or as encoded plaintext based on the Content-Type header field.
public var parameters: OAuthSwift.Parameters
public let paramsLocation: ParamsLocation
public let dataEncoding: String.Encoding
public let sessionFactory: URLSessionFactory
/// Shortcut
public var httpMethod: Method {
if let requestMethod = urlRequest.httpMethod {
return Method(rawValue: requestMethod) ?? .GET
}
return .GET
}
public var url: Foundation.URL? {
return urlRequest.url
}
// MARK: init
public init(url: URL, httpMethod: Method = .GET, httpBody: Data? = nil, headers: OAuthSwift.Headers = [:], timeoutInterval: TimeInterval = 60, httpShouldHandleCookies: Bool = false, parameters: OAuthSwift.Parameters, paramsLocation: ParamsLocation = .authorizationHeader, dataEncoding: String.Encoding = OAuthSwiftDataEncoding, sessionFactory: URLSessionFactory = .default) {
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = httpMethod.rawValue
urlRequest.httpBody = httpBody
urlRequest.allHTTPHeaderFields = headers
urlRequest.timeoutInterval = timeoutInterval
urlRequest.httpShouldHandleCookies = httpShouldHandleCookies
self.init(urlRequest: urlRequest, parameters: parameters, paramsLocation: paramsLocation, dataEncoding: dataEncoding, sessionFactory: sessionFactory)
}
public init(urlRequest: URLRequest, parameters: OAuthSwift.Parameters = [:], paramsLocation: ParamsLocation = .authorizationHeader, dataEncoding: String.Encoding = OAuthSwiftDataEncoding, sessionFactory: URLSessionFactory = .default) {
self.urlRequest = urlRequest
self.parameters = parameters
self.paramsLocation = paramsLocation
self.dataEncoding = dataEncoding
self.sessionFactory = sessionFactory
}
/// Modify request with authentification
public mutating func updateRequest(credential: OAuthSwiftCredential) {
let method = self.httpMethod
let url = self.urlRequest.url!
let headers: OAuthSwift.Headers = self.urlRequest.allHTTPHeaderFields ?? [:]
let paramsLocation = self.paramsLocation
let parameters = self.parameters
var signatureUrl = url
var signatureParameters = parameters
// Check if body must be hashed (oauth1)
let body: Data? = nil
if method.isBody {
if let contentType = headers[kHTTPHeaderContentType]?.lowercased() {
if contentType.contains("application/json") {
// TODO: oauth_body_hash create body before signing if implementing body hashing
/*do {
let jsonData: Data = try JSONSerialization.jsonObject(parameters, options: [])
request.HTTPBody = jsonData
requestHeaders["Content-Length"] = "\(jsonData.length)"
body = jsonData
}
catch {
}*/
signatureParameters = [:] // parameters are not used for general signature (could only be used for body hashing
}
// else other type are not supported, see setupRequestForOAuth()
}
}
// Need to account for the fact that some consumers will have additional parameters on the
// querystring, including in the case of fetching a request token. Especially in the case of
// additional parameters on the request, authorize, or access token exchanges, we need to
// normalize the URL and add to the parametes collection.
var queryStringParameters = OAuthSwift.Parameters()
var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false )
if let queryItems = urlComponents?.queryItems {
for queryItem in queryItems {
let value = queryItem.value?.safeStringByRemovingPercentEncoding ?? ""
queryStringParameters.updateValue(value, forKey: queryItem.name)
}
}
// According to the OAuth1.0a spec, the url used for signing is ONLY scheme, path, and query
if !queryStringParameters.isEmpty {
urlComponents?.query = nil
// This is safe to unwrap because these just came from an NSURL
signatureUrl = urlComponents?.url ?? url
}
signatureParameters = signatureParameters.join(queryStringParameters)
var requestHeaders = OAuthSwift.Headers()
switch paramsLocation {
case .authorizationHeader:
// Add oauth parameters in the Authorization header
requestHeaders += credential.makeHeaders(signatureUrl, method: method, parameters: signatureParameters, body: body)
case .requestURIQuery:
// Add oauth parameters as request parameters
self.parameters += credential.authorizationParametersWithSignature(method: method, url: signatureUrl, parameters: signatureParameters, body: body)
}
self.urlRequest.allHTTPHeaderFields = requestHeaders + headers
}
}
}
// MARK: - session configuration
/// configure how URLSession is initialized
public struct URLSessionFactory {
public static let `default` = URLSessionFactory()
public var configuration = URLSessionConfiguration.default
public var queue = OperationQueue.main
/// An optional delegate for the URLSession
public weak var delegate: URLSessionDelegate?
/// Monitor session: see UIApplication.shared.isNetworkActivityIndicatorVisible
public var isNetworkActivityIndicatorVisible = true
/// By default use a closure to receive data from server.
/// If you set to false, you must in `delegate` take care of server response.
/// and maybe call in delegate `OAuthSwiftHTTPRequest.completionHandler`
public var useDataTaskClosure = true
/// Create a new URLSession
func build() -> URLSession {
return URLSession(configuration: self.configuration, delegate: self.delegate, delegateQueue: self.queue)
}
}
// MARK: - status code mapping
extension OAuthSwiftHTTPRequest {
class func descriptionForHTTPStatus(_ status: Int, responseString: String) -> String {
var s = "HTTP Status \(status)"
var description: String?
// http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml
if status == 400 { description = "Bad Request" }
if status == 401 { description = "Unauthorized" }
if status == 402 { description = "Payment Required" }
if status == 403 { description = "Forbidden" }
if status == 404 { description = "Not Found" }
if status == 405 { description = "Method Not Allowed" }
if status == 406 { description = "Not Acceptable" }
if status == 407 { description = "Proxy Authentication Required" }
if status == 408 { description = "Request Timeout" }
if status == 409 { description = "Conflict" }
if status == 410 { description = "Gone" }
if status == 411 { description = "Length Required" }
if status == 412 { description = "Precondition Failed" }
if status == 413 { description = "Payload Too Large" }
if status == 414 { description = "URI Too Long" }
if status == 415 { description = "Unsupported Media Type" }
if status == 416 { description = "Requested Range Not Satisfiable" }
if status == 417 { description = "Expectation Failed" }
if status == 422 { description = "Unprocessable Entity" }
if status == 423 { description = "Locked" }
if status == 424 { description = "Failed Dependency" }
if status == 425 { description = "Unassigned" }
if status == 426 { description = "Upgrade Required" }
if status == 427 { description = "Unassigned" }
if status == 428 { description = "Precondition Required" }
if status == 429 { description = "Too Many Requests" }
if status == 430 { description = "Unassigned" }
if status == 431 { description = "Request Header Fields Too Large" }
if status == 432 { description = "Unassigned" }
if status == 500 { description = "Internal Server Error" }
if status == 501 { description = "Not Implemented" }
if status == 502 { description = "Bad Gateway" }
if status == 503 { description = "Service Unavailable" }
if status == 504 { description = "Gateway Timeout" }
if status == 505 { description = "HTTP Version Not Supported" }
if status == 506 { description = "Variant Also Negotiates" }
if status == 507 { description = "Insufficient Storage" }
if status == 508 { description = "Loop Detected" }
if status == 509 { description = "Unassigned" }
if status == 510 { description = "Not Extended" }
if status == 511 { description = "Network Authentication Required" }
if description != nil {
s += ": " + description! + ", Response: " + responseString
}
return s
}
}