504 lines
24 KiB
Swift
504 lines
24 KiB
Swift
//
|
|
// Copyright (c) 2020 Loverde Co.
|
|
//
|
|
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
// of this software and associated documentation files (the "Software"), to deal
|
|
// in the Software without restriction, including without limitation the rights
|
|
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
// copies of the Software, and to permit persons to whom the Software is
|
|
// furnished to do so, subject to the following conditions:
|
|
//
|
|
// The above copyright notice and this permission notice shall be included in
|
|
// all copies or substantial portions of the Software.
|
|
//
|
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
// THE SOFTWARE.
|
|
|
|
|
|
import Foundation
|
|
#if os(iOS) || os(watchOS)
|
|
#if canImport(Security)
|
|
import Security
|
|
#endif
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
/// A generic `Result` enumeration to represent either a success `Value` or a failure `Error`.
|
|
public enum Result<Value, Error: Swift.Error> {
|
|
/// Indicates a successful operation with an associated `Value`.
|
|
case success(Value)
|
|
/// Indicates a failed operation with an associated `Error`.
|
|
case failure(Error)
|
|
}
|
|
|
|
/// Enumeration defining common HTTP methods.
|
|
public enum httpMethod: String {
|
|
/// The POST method.
|
|
case post = "POST"
|
|
/// The GET method.
|
|
case get = "GET"
|
|
/// The PUT method.
|
|
case put = "PUT"
|
|
/// The DELETE method.
|
|
case delete = "DELETE"
|
|
}
|
|
|
|
/// Loverde Co.: API generic struct for simple requests.
|
|
///
|
|
/// This struct provides a convenient way to perform network requests with various configurations,
|
|
/// including handling different HTTP methods, parameter encoding, and certificate-based authentication.
|
|
@available(iOS 13.0.0, *)
|
|
@MainActor
|
|
public struct API {
|
|
|
|
private static var certData: Data?
|
|
private static var certPassword: String?
|
|
|
|
/// The default error used when an unexpected issue occurs during a request.
|
|
static let defaultError = NSError.createErrorWith(code: LCEssentials.DEFAULT_ERROR_CODE,
|
|
description: LCEssentials.DEFAULT_ERROR_MSG,
|
|
reasonForError: LCEssentials.DEFAULT_ERROR_MSG)
|
|
|
|
/// The delay in seconds before retrying a persistent connection request.
|
|
public static var persistConnectionDelay: Double = 3
|
|
|
|
/// Default parameters that will be included in all requests unless explicitly overridden.
|
|
public static var defaultParams: [String:Any] = [String: Any]()
|
|
|
|
/// Default HTTP headers for requests.
|
|
///
|
|
/// By default, it includes "Accept", "Content-Type", and "Accept-Encoding" headers.
|
|
var defaultHeaders: [String: String] = ["Accept": "application/json",
|
|
"Content-Type": "application/json; charset=UTF-8",
|
|
"Accept-Encoding": "gzip"]
|
|
|
|
/// The shared singleton instance of the `API` struct.
|
|
public static let shared = API()
|
|
|
|
private init(){}
|
|
|
|
/// Performs an asynchronous network request and decodes the response into a `Codable` type.
|
|
///
|
|
/// - Parameters:
|
|
/// - url: The URL string for the request.
|
|
/// - params: Optional parameters for the request. Can be `[String: Any]` for JSON/form-data, or `Data` for raw body.
|
|
/// - method: The HTTP method to use for the request (`.get`, `.post`, `.put`, `.delete`).
|
|
/// - headers: Optional custom HTTP headers to be added to the request. These override default headers if there are conflicts.
|
|
/// - jsonEncoding: A boolean indicating whether parameters should be JSON encoded. Defaults to `true`.
|
|
/// - debug: A boolean indicating whether to print debug logs for the request and response. Defaults to `true`.
|
|
/// - timeoutInterval: The timeout interval in seconds for the request. Defaults to `30`.
|
|
/// - networkServiceType: The `URLRequest.NetworkServiceType` for the request. Defaults to `.default`.
|
|
/// - persistConnection: A boolean indicating whether to persist the connection on certain error codes (e.g., 4xx). Defaults to `false`.
|
|
/// - Returns: An instance of the `T` type, decoded from the response data.
|
|
/// - Throws: An `Error` if the request fails, including `URLError` for network issues or `DecodingError` for JSON decoding failures.
|
|
public func request<T: Codable>(url: String,
|
|
params: Any? = nil,
|
|
method: httpMethod,
|
|
headers: [String: String] = [:],
|
|
jsonEncoding: Bool = true,
|
|
debug: Bool = true,
|
|
timeoutInterval: TimeInterval = 30,
|
|
networkServiceType: URLRequest.NetworkServiceType = .default,
|
|
persistConnection: Bool = false) async throws -> T {
|
|
|
|
if let urlReq = URL(string: url.replaceURL(params as? [String: Any] ?? [:] )) {
|
|
var request = URLRequest(url: urlReq, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
|
if method == .post || method == .put || method == .delete {
|
|
if let params = params as? [String: Any],
|
|
let pathFile = params["file"] as? String,
|
|
let fileURL = URL(string: pathFile) {
|
|
let boundary = UUID().uuidString
|
|
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
|
|
|
var body = Data()
|
|
|
|
// Add additional fields (if any)
|
|
for (key, value) in params where key != "file" {
|
|
let stringValue = "\(value)"
|
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
body.append("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".data(using: .utf8)!)
|
|
body.append("\(stringValue)\r\n".data(using: .utf8)!)
|
|
}
|
|
|
|
// Add the file
|
|
let fileName = fileURL.lastPathComponent
|
|
let mimeType = mimeTypeForPath(path: fileName)
|
|
do {
|
|
let fileData = try Data(contentsOf: fileURL)
|
|
body.append("--\(boundary)\r\n".data(using: .utf8)!)
|
|
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
|
|
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
|
|
body.append(fileData)
|
|
body.append("\r\n".data(using: .utf8)!)
|
|
} catch {
|
|
printError(title: "Upload File", msg: error.localizedDescription)
|
|
}
|
|
|
|
// Finalize the request body
|
|
body.append("--\(boundary)--\r\n".data(using: .utf8)!)
|
|
request.httpBody = body
|
|
request.setValue("\(body.count)", forHTTPHeaderField: "Content-Length")
|
|
// Debug logs
|
|
printLog(title: "Boundary", msg: boundary)
|
|
if let bodyString = String(data: body, encoding: .utf8) {
|
|
printLog(title: "Body Content", msg: bodyString)
|
|
}
|
|
} else if jsonEncoding, let params = params as? [String: Any] {
|
|
let requestObject = try JSONSerialization.data(withJSONObject: params)
|
|
request.httpBody = requestObject
|
|
} else if let params = params as? [String: Any] {
|
|
var bodyComponents = URLComponents()
|
|
params.forEach({ (key, value) in
|
|
bodyComponents.queryItems?.append(URLQueryItem(name: key, value: value as? String))
|
|
})
|
|
request.httpBody = bodyComponents.query?.data(using: .utf8)
|
|
} else if let params = params as? Data {
|
|
request.httpBody = params
|
|
}
|
|
}
|
|
request.httpMethod = method.rawValue
|
|
request.timeoutInterval = timeoutInterval
|
|
request.networkServiceType = networkServiceType
|
|
|
|
// - Put Default Headers together with user defined params
|
|
if !headers.isEmpty {
|
|
// - Add it to request
|
|
headers.forEach { (key, value) in
|
|
request.addValue(value, forHTTPHeaderField: key)
|
|
}
|
|
}else{
|
|
defaultHeaders.forEach { (key, value) in
|
|
request.addValue(value, forHTTPHeaderField: key)
|
|
}
|
|
}
|
|
if debug {
|
|
API.requestLOG(method: method, request: request)
|
|
}
|
|
|
|
let session = URLSession(
|
|
configuration: .default,
|
|
delegate: URLSessionDelegateHandler(
|
|
certData: API.certData,
|
|
password: API.certPassword
|
|
),
|
|
delegateQueue: nil
|
|
)
|
|
do {
|
|
let (data, response) = try await session.data(for: request)
|
|
|
|
|
|
var code: Int = LCEssentials.DEFAULT_ERROR_CODE
|
|
let httpResponse = response as? HTTPURLResponse ?? HTTPURLResponse()
|
|
code = httpResponse.statusCode
|
|
let error = URLError(URLError.Code(rawValue: code))
|
|
switch code {
|
|
case 200..<300:
|
|
// - Debug LOG
|
|
if debug {
|
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: nil)
|
|
}
|
|
|
|
// - Check if is JSON result and try decode it
|
|
if let string = data.string as? T, T.self == String.self {
|
|
return string
|
|
}
|
|
// - Normal decoding
|
|
do {
|
|
return try JSONDecoder.decode(data: data)
|
|
} catch {
|
|
printError(title: "JSONDecoder", msg: error.localizedDescription)
|
|
throw error
|
|
}
|
|
case 400..<500:
|
|
// - Debug LOG
|
|
if debug {
|
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
}
|
|
if persistConnection {
|
|
printError(title: "INTERNET CONNECTION ERROR", msg: "WILL PERSIST")
|
|
// Recursive call for persistence
|
|
let persist: T = try await self.request(
|
|
url: url,
|
|
params: params,
|
|
method: method,
|
|
headers: headers,
|
|
jsonEncoding: jsonEncoding,
|
|
debug: debug,
|
|
timeoutInterval: timeoutInterval,
|
|
networkServiceType: networkServiceType,
|
|
persistConnection: persistConnection
|
|
)
|
|
return persist
|
|
} else {
|
|
if debug {
|
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
}
|
|
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
|
throw friendlyError
|
|
}
|
|
default:
|
|
// - Debug LOG
|
|
if debug {
|
|
API.responseLOG(method: method, request: request, data: data, statusCode: code, error: error)
|
|
}
|
|
let friendlyError = NSError.createErrorWith(code: code, description: error.localizedDescription, reasonForError: data.prettyJson ?? "")
|
|
throw friendlyError
|
|
}
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
throw API.defaultError
|
|
}
|
|
|
|
/// Sets up client certificate data and an optional password for authentication.
|
|
///
|
|
/// - Parameters:
|
|
/// - certData: The `Data` representation of the client certificate (e.g., a .p12 file).
|
|
/// - password: The password for the certificate, if required. Defaults to an empty string.
|
|
public func setupCertificationRequest(certData: Data, password: String = "") {
|
|
API.certData = certData
|
|
API.certPassword = password
|
|
}
|
|
}
|
|
|
|
#if canImport(Security)
|
|
/// A custom `URLSessionDelegate` handler for managing URL session challenges,
|
|
/// particularly for client and server trust authentication.
|
|
@available(iOS 13.0.0, *)
|
|
@MainActor
|
|
private class URLSessionDelegateHandler: NSObject, URLSessionDelegate {
|
|
|
|
private var certData: Data?
|
|
private var certPass: String?
|
|
|
|
/// Initializes a new `URLSessionDelegateHandler` instance.
|
|
///
|
|
/// - Parameters:
|
|
/// - certData: Optional `Data` for the client certificate.
|
|
/// - password: Optional password for the client certificate.
|
|
init(certData: Data? = nil, password: String? = nil) {
|
|
super.init()
|
|
self.certData = certData
|
|
self.certPass = password
|
|
}
|
|
|
|
/// Handles URL session authentication challenges.
|
|
///
|
|
/// This method is responsible for providing client certificates for client certificate
|
|
/// authentication and validating server trust for server authentication.
|
|
///
|
|
/// - Parameters:
|
|
/// - session: The URL session that issued the challenge.
|
|
/// - challenge: The authentication challenge.
|
|
/// - Returns: A tuple containing the disposition for the challenge and the credential to use.
|
|
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) {
|
|
if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
|
|
// Load the client certificate
|
|
guard let identity = getIdentity() else {
|
|
return (URLSession.AuthChallengeDisposition.performDefaultHandling, nil)
|
|
}
|
|
|
|
// Create the URLCredential with the identity
|
|
let credential = URLCredential(identity: identity, certificates: nil, persistence: .forSession)
|
|
return (URLSession.AuthChallengeDisposition.useCredential, credential)
|
|
} else if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust {
|
|
// Validate the server certificate
|
|
if let serverTrust = challenge.protectionSpace.serverTrust {
|
|
let serverCredential = URLCredential(trust: serverTrust)
|
|
return (URLSession.AuthChallengeDisposition.useCredential, serverCredential)
|
|
} else {
|
|
return (URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil)
|
|
}
|
|
} else {
|
|
return (URLSession.AuthChallengeDisposition.performDefaultHandling, nil)
|
|
}
|
|
}
|
|
|
|
/// Retrieves the `SecIdentity` from the provided certificate data and password.
|
|
///
|
|
/// - Returns: A `SecIdentity` object if the certificate is successfully imported, otherwise `nil`.
|
|
private func getIdentity() -> SecIdentity? {
|
|
guard let certData = self.certData else { return nil }
|
|
// Specify the password used when exporting the .p12
|
|
let options: [String: Any] = [kSecImportExportPassphrase as String: self.certPass ?? ""]
|
|
var items: CFArray?
|
|
|
|
// Import the .p12 certificate to get the identity
|
|
let status = SecPKCS12Import(certData as CFData, options as CFDictionary, &items)
|
|
|
|
if status == errSecSuccess,
|
|
let item = (items as? [[String: Any]])?.first,
|
|
let identityRef = item[kSecImportItemIdentity as String] as CFTypeRef?,
|
|
CFGetTypeID(identityRef) == SecIdentityGetTypeID() {
|
|
return (identityRef as! SecIdentity)
|
|
} else {
|
|
print("Erro ao importar a identidade do certificado: \(status)")
|
|
return nil
|
|
}
|
|
|
|
}
|
|
|
|
/// Checks if the provided data is a valid PKCS#12 (P12) certificate with the given password.
|
|
///
|
|
/// - Parameters:
|
|
/// - data: The `Data` representation of the certificate.
|
|
/// - password: The password for the certificate.
|
|
/// - Returns: `true` if the data is a valid P12 certificate with the given password, otherwise `false`.
|
|
private func isPKCS12(data: Data, password: String) -> Bool {
|
|
let options: [String: Any] = [kSecImportExportPassphrase as String: password]
|
|
|
|
var items: CFArray?
|
|
let status = SecPKCS12Import(data as CFData, options as CFDictionary, &items)
|
|
|
|
return status == errSecSuccess
|
|
}
|
|
}
|
|
#endif
|
|
|
|
extension Error {
|
|
/// Returns the status code of the error, if available.
|
|
public var statusCode: Int {
|
|
get{
|
|
return self._code
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 13.0.0, *)
|
|
extension API {
|
|
|
|
/// Logs details of an outgoing network request for debugging purposes.
|
|
///
|
|
/// - Parameters:
|
|
/// - method: The HTTP method of the request.
|
|
/// - request: The `URLRequest` object.
|
|
fileprivate static func requestLOG(method: httpMethod, request: URLRequest) {
|
|
|
|
print("\n<========================= 🟠 INTERNET CONNECTION - REQUEST =========================>")
|
|
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
|
|
printLog(title: "METHOD", msg: method.rawValue)
|
|
printLog(title: "REQUEST", msg: String(describing: request))
|
|
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
|
|
|
|
//
|
|
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
|
|
printLog(title: "PARAMETERS", msg: prettyJson)
|
|
} else if let dataBody = request.httpBody {
|
|
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
|
|
}
|
|
//
|
|
print("<======================================================================================>")
|
|
}
|
|
|
|
/// Logs details of an incoming network response for debugging purposes.
|
|
///
|
|
/// - Parameters:
|
|
/// - method: The HTTP method of the original request.
|
|
/// - request: The `URLRequest` object that generated this response.
|
|
/// - data: The data received in the response.
|
|
/// - statusCode: The HTTP status code of the response.
|
|
/// - error: An optional `Error` object if the request failed.
|
|
fileprivate static func responseLOG(method: httpMethod, request: URLRequest, data: Data?, statusCode: Int, error: Error?) {
|
|
///
|
|
let icon = error != nil ? "🔴" : "🟢"
|
|
print("\n<========================= \(icon) INTERNET CONNECTION - RESPONSE =========================>")
|
|
printLog(title: "DATE AND TIME", msg: Date().debugDescription)
|
|
printLog(title: "METHOD", msg: method.rawValue)
|
|
printLog(title: "REQUEST", msg: String(describing: request))
|
|
printLog(title: "HEADERS", msg: request.allHTTPHeaderFields?.debugDescription ?? "")
|
|
|
|
//
|
|
if let dataBody = request.httpBody, let prettyJson = dataBody.prettyJson {
|
|
printLog(title: "PARAMETERS", msg: prettyJson)
|
|
} else if let dataBody = request.httpBody {
|
|
printLog(title: "PARAMETERS", msg: String(data: dataBody, encoding: .utf8) ?? "-")
|
|
}
|
|
//
|
|
printLog(title: "STATUS CODE", msg: String(describing: statusCode))
|
|
//
|
|
if let dataResponse = data, let prettyJson = dataResponse.prettyJson {
|
|
printLog(title: "RESPONSE", msg: prettyJson)
|
|
} else {
|
|
printLog(title: "RESPONSE", msg: String(data: data ?? Data(), encoding: .utf8) ?? "-")
|
|
}
|
|
//
|
|
if let error = error {
|
|
switch error.statusCode {
|
|
case NSURLErrorTimedOut:
|
|
printError(title: "RESPONSE ERROR TIMEOUT", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
case NSURLErrorNotConnectedToInternet:
|
|
printError(title: "RESPONSE ERROR NO INTERNET", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
case NSURLErrorNetworkConnectionLost:
|
|
printError(title: "RESPONSE ERROR CONNECTION LOST", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
case NSURLErrorCancelledReasonUserForceQuitApplication:
|
|
printError(title: "RESPONSE ERROR APP QUIT", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
case NSURLErrorCancelledReasonBackgroundUpdatesDisabled:
|
|
printError(title: "RESPONSE ERROR BG DISABLED", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
case NSURLErrorBackgroundSessionWasDisconnected:
|
|
printError(title: "RESPONSE ERROR BG SESSION DISCONNECTED", msg: "DESCRICAO: \(error.localizedDescription)")
|
|
|
|
default:
|
|
printError(title: "GENERAL", msg: error.localizedDescription)
|
|
}
|
|
}else if let data = data, statusCode != 200 {
|
|
// - Check if is JSON result
|
|
if let jsonString = String(data: data, encoding: .utf8) {
|
|
printError(title: "JSON STATUS CODE \(statusCode)", msg: jsonString)
|
|
}else{
|
|
printError(title: "DATA STATUS CODE \(statusCode)", msg: data.debugDescription)
|
|
}
|
|
}
|
|
//
|
|
print("<======================================================================================>")
|
|
}
|
|
|
|
/// Determines the MIME type for a given file path based on its extension.
|
|
///
|
|
/// - Parameter path: The file path string.
|
|
/// - Returns: A string representing the MIME type. Defaults to "application/octet-stream" if the type is unknown.
|
|
func mimeTypeForPath(path: String) -> String {
|
|
let url = URL(fileURLWithPath: path)
|
|
let pathExtension = url.pathExtension.lowercased()
|
|
|
|
// Dictionary of common extensions and MIME types
|
|
let mimeTypes: [String: String] = [
|
|
"jpg": "image/jpeg",
|
|
"jpeg": "image/jpeg",
|
|
"png": "image/png",
|
|
"gif": "image/gif",
|
|
"pdf": "application/pdf",
|
|
"txt": "text/plain",
|
|
"html": "text/html",
|
|
"htm": "text/html",
|
|
"json": "application/json",
|
|
"xml": "application/xml",
|
|
"zip": "application/zip",
|
|
"mp3": "audio/mpeg",
|
|
"mp4": "video/mp4",
|
|
"mov": "video/quicktime",
|
|
"doc": "application/msword",
|
|
"docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"xls": "application/vnd.ms-excel",
|
|
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"ppt": "application/vnd.ms-powerpoint",
|
|
"pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
]
|
|
|
|
// Returns the corresponding MIME type for the extension, or "application/octet-stream" as default
|
|
return mimeTypes[pathExtension] ?? "application/octet-stream"
|
|
}
|
|
}
|
|
#endif
|