Files
LCEssentials/Sources/LCEssentials/Classes/LCEssentials+API.swift
Daniel Arantes Loverde 0910973e9a documentation
2025-06-23 09:48:57 -03:00

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