diff --git a/Sources/MBNetworking/Extensions/CharacterEncoding.swift b/Sources/MBNetworking/Extensions/CharacterEncoding.swift index 0fbeeba..4fb440b 100644 --- a/Sources/MBNetworking/Extensions/CharacterEncoding.swift +++ b/Sources/MBNetworking/Extensions/CharacterEncoding.swift @@ -23,7 +23,7 @@ extension CharacterSet { let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 let subDelimitersToEncode = "!$&'()*+,;=" let encodableDelimiters = CharacterSet(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") - + return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters) }() } diff --git a/Sources/MBNetworking/Extensions/Data.swift b/Sources/MBNetworking/Extensions/Data.swift index b62f5e2..52f582c 100644 --- a/Sources/MBNetworking/Extensions/Data.swift +++ b/Sources/MBNetworking/Extensions/Data.swift @@ -9,10 +9,8 @@ import Foundation extension NSMutableData { - func appendString(_ string: String) { guard let data = string.data(using: .utf8) else { return } append(data) } - } diff --git a/Sources/MBNetworking/Extensions/ProcessInfo.swift b/Sources/MBNetworking/Extensions/ProcessInfo.swift index 325ba85..e6a2674 100644 --- a/Sources/MBNetworking/Extensions/ProcessInfo.swift +++ b/Sources/MBNetworking/Extensions/ProcessInfo.swift @@ -9,10 +9,8 @@ import Foundation extension ProcessInfo { - /// Returns true if process is in testing. static var isUnderTest: Bool { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } - } diff --git a/Sources/MBNetworking/Extensions/SecTrust.swift b/Sources/MBNetworking/Extensions/SecTrust.swift index a853e4d..92b3acc 100644 --- a/Sources/MBNetworking/Extensions/SecTrust.swift +++ b/Sources/MBNetworking/Extensions/SecTrust.swift @@ -7,7 +7,7 @@ import Foundation -public extension Array where Element == SecCertificate { +public extension [SecCertificate] { var publicKeys: [SecKey] { compactMap(\.publicKey) } @@ -32,9 +32,9 @@ public extension SecCertificate { public extension SecTrust { var certificates: [SecCertificate] { if #available(iOS 15, macOS 12, watchOS 8, *) { - return (SecTrustCopyCertificateChain(self) as? [SecCertificate]) ?? [] + (SecTrustCopyCertificateChain(self) as? [SecCertificate]) ?? [] } else { - return (0 ..< SecTrustGetCertificateCount(self)).compactMap { index in + (0 ..< SecTrustGetCertificateCount(self)).compactMap { index in SecTrustGetCertificateAtIndex(self, index) } } diff --git a/Sources/MBNetworking/Extensions/URL.swift b/Sources/MBNetworking/Extensions/URL.swift index 8591c96..68e570e 100644 --- a/Sources/MBNetworking/Extensions/URL.swift +++ b/Sources/MBNetworking/Extensions/URL.swift @@ -8,17 +8,16 @@ import Foundation -extension URL { - +public extension URL { /// Initializes URL with string. Same as URL(string:), but returns URL (not optional.) /// In case of failure in initializing, fatal error will be thrown. - public init(forceString string: String) { + init(forceString string: String) { guard let url = URL(string: string) else { fatalError("Could not init URL '\(string)'") } self = url } - + /// Returns URL by settings URL.queryItems to specified parameters. - public func adding(parameters: [String: String]) -> URL { + func adding(parameters: [String: String]) -> URL { guard parameters.count > 0 else { return self } var queryItems: [URLQueryItem] = [] for parameter in parameters { @@ -26,7 +25,7 @@ extension URL { } return adding(queryItems: queryItems) } - + /// Returns URL by settings URL.queryItems to specified queryItems. private func adding(queryItems: [URLQueryItem]) -> URL { guard var urlComponents = URLComponents(string: absoluteString) else { @@ -38,5 +37,4 @@ extension URL { } return url } - } diff --git a/Sources/MBNetworking/Networkable+DataTask.swift b/Sources/MBNetworking/Networkable+DataTask.swift index b281e47..b41bf9b 100644 --- a/Sources/MBNetworking/Networkable+DataTask.swift +++ b/Sources/MBNetworking/Networkable+DataTask.swift @@ -1,5 +1,5 @@ // -// Network.swift +// Networkable+DataTask.swift // Network // // Created by Rasid Ramazanov on 25.11.2019. @@ -19,12 +19,11 @@ extension Networkable { _ type: V.Type, completion: @escaping ((Result) -> Void) ) { + fetch(request, completion: completion) + // StubURLProtocol enabled and adding a small delay. if StubURLProtocol.isEnabled, ProcessInfo.isUnderTest { - fetch(request, completion: completion) RunLoop.current.run(until: Date().addingTimeInterval(0.05)) - } else { - fetch(request, completion: completion) } } @@ -34,19 +33,18 @@ extension Networkable { ) { requestData(urlRequest) { response, data, error in - if let error = error, + if let error, self.isNetworkConnectionError((error as NSError).code) { let error = MBErrorKit.NetworkingError.networkConnectionError(error) MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) self.printErrorLog(error) completion(.failure(error)) - } else if let error = error { - let networkingError: NetworkingError - if (error as NSError).code == NSURLErrorCancelled { - networkingError = .dataTaskCancelled + } else if let error { + let networkingError: NetworkingError = if (error as NSError).code == NSURLErrorCancelled { + .dataTaskCancelled } else { - networkingError = MBErrorKit.NetworkingError.underlyingError(error, response, data) + MBErrorKit.NetworkingError.underlyingError(error, response, data) } MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: networkingError) self.printErrorLog(networkingError) @@ -59,13 +57,13 @@ extension Networkable { self.printErrorLog(error) completion(.failure(error)) - } else if let response = response, data == nil || data?.count == 0 { + } else if let response, data == nil || data?.count == 0 { let error = MBErrorKit.NetworkingError.dataTaskError(response, data) MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) self.printErrorLog(error) completion(.failure(error)) - } else if let data = data, data.count > 0 { + } else if let data, !data.isEmpty { do { // If requested decodable type is Data, received data will be returned. if V.Type.self == Data.Type.self { @@ -111,9 +109,9 @@ extension Networkable { } Session.shared.networkLogMonitoringDelegate?.logTask(task: task, didCompleteWithError: error) } - + Session.shared.tasksInProgress.removeValue(forKey: taskId) - + self.printResponse(data) DispatchQueue.main.async { completion(response, data, error) diff --git a/Sources/MBNetworking/Networkable+DataTaskAsync.swift b/Sources/MBNetworking/Networkable+DataTaskAsync.swift new file mode 100644 index 0000000..4ac99e4 --- /dev/null +++ b/Sources/MBNetworking/Networkable+DataTaskAsync.swift @@ -0,0 +1,90 @@ +// +// Networkable+DataTaskAsync.swift +// Network +// +// Created by Rasid Ramazanov on 25.11.2019. +// Copyright © 2019 LeanScale. All rights reserved. +// + +import Foundation +import MBErrorKit + +/// Networkable extension related to data tasks. +/// +@available(iOS 13.0.0, *) extension Networkable { + /// Fetch data with specified parameters and return back with the completion. + /// - Parameters: + /// - type: Type of the result. + /// - Returns: Response as `Result` + public func fetch(_ type: V.Type) async throws -> V { + try await fetch(request) + } + + private func fetch(_ urlRequest: URLRequest) async throws -> V { + let (response, data, error) = await requestData(urlRequest) + + if let error, + isNetworkConnectionError((error as NSError).code) { + let error = MBErrorKit.NetworkingError.networkConnectionError(error) + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) + printErrorLog(error) + throw error + + } else if let error { + let networkingError: NetworkingError = if (error as NSError).code == NSURLErrorCancelled { + .dataTaskCancelled + } else { + MBErrorKit.NetworkingError.underlyingError(error, response, data) + } + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: networkingError) + printErrorLog(networkingError) + throw networkingError + + } else if let httpResponse = response as? HTTPURLResponse, + isSuccess(httpResponse.statusCode) { + let error = MBErrorKit.NetworkingError.httpError(error, httpResponse, data) + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) + printErrorLog(error) + throw error + + } else if let response, data == nil || data?.count == 0 { + let error = MBErrorKit.NetworkingError.dataTaskError(response, data) + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) + printErrorLog(error) + throw error + + } else if let data, data.count > 0 { + do { + // If requested decodable type is Data, received data will be returned. + if V.Type.self == Data.Type.self { + return data as! V + } + let decodableData = try JSONDecoder().decode(V.self, from: data) + return decodableData + } catch let sError { + let error = MBErrorKit.NetworkingError.decodingError(sError, response, data) + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(serializationError: error) + self.printErrorLog(error) + throw error + } + + } else { + let error = MBErrorKit.NetworkingError.unkownError(error, data) + MBErrorKit.ErrorKit.shared().delegate?.errorKitDidCatch(networkingError: error) + printErrorLog(error) + throw error + } + } + + private func requestData(_ urlRequest: URLRequest) async -> (URLResponse?, Data?, Error?) { + // TODO: Should async support Session.shared.taskInProgress cancellation logic? + + do { + let (data, response) = try await Session.shared.session.data(for: urlRequest) + printResponse(data) + return (response, data, nil) + } catch { + return (nil, nil, error) + } + } +} diff --git a/Sources/MBNetworking/Networkable+Logs.swift b/Sources/MBNetworking/Networkable+Logs.swift index 730206a..a10ab47 100644 --- a/Sources/MBNetworking/Networkable+Logs.swift +++ b/Sources/MBNetworking/Networkable+Logs.swift @@ -54,7 +54,7 @@ extension Networkable { } private func getStringFrom(_ data: Data?) -> String { - if let data = data { + if let data { if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), !(jsonObject is NSNull), let json = try? JSONSerialization.data(withJSONObject: jsonObject, options: .prettyPrinted), diff --git a/Sources/MBNetworking/Networkable+Pinning.swift b/Sources/MBNetworking/Networkable+Pinning.swift deleted file mode 100644 index 1e82e85..0000000 --- a/Sources/MBNetworking/Networkable+Pinning.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// Networkable+Pinning.swift -// Networking -// -// Created by Rasid Ramazanov on 29.01.2020. -// Copyright © 2020 Mobven. All rights reserved. -// - -import Foundation -import Security - -internal class URLSessionPinningDelegate: NSObject, URLSessionDelegate { - var certificatePaths: [String] = [] - - func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - guard let serverCertificate = getServerCertificate(forChallenge: challenge) else { - completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil) - return - } - - let serverPublicKeys = serverCertificate.trust.certificates.publicKeys - for certificatePath in certificatePaths { - if let localCertificateData = try? Data(contentsOf: URL(fileURLWithPath: certificatePath)) as CFData?, - let localCertificate = SecCertificateCreateWithData(nil, localCertificateData), - let localPublicKey = localCertificate.publicKey { - if serverPublicKeys.contains(localPublicKey) { - completionHandler( - URLSession.AuthChallengeDisposition.useCredential, URLCredential(trust: serverCertificate.trust) - ) - return - } - } - } - if certificatePaths.count == 0 { - // No SSL pinning. Performing default handling. - completionHandler(URLSession.AuthChallengeDisposition.performDefaultHandling, nil) - } else { - // SSL pinning could not succeed with given certificates. Cancelling authentication. - completionHandler(URLSession.AuthChallengeDisposition.cancelAuthenticationChallenge, nil) - } - } - - private func getServerCertificate( - forChallenge challenge: URLAuthenticationChallenge - ) -> (data: CFData, trust: SecTrust)? { - var secresult = SecTrustResultType.invalid - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, - let serverTrust = challenge.protectionSpace.serverTrust, - errSecSuccess == SecTrustEvaluate(serverTrust, &secresult), - let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) - else { - return nil - } - - let serverCertificateData = SecCertificateCopyData(serverCertificate) - return (serverCertificateData, serverTrust) - } -} - -extension URLSessionPinningDelegate: URLSessionTaskDelegate { - func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { - Session.shared.networkLogMonitoringDelegate?.logTask(task: task, didFinishCollecting: metrics) - } -} - -internal class UntrustedURLSessionDelegate: NSObject, URLSessionDelegate { - func urlSession( - _ session: URLSession, - didReceive challenge: URLAuthenticationChallenge, - completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void - ) { - completionHandler( - URLSession.AuthChallengeDisposition.useCredential, - URLCredential(trust: challenge.protectionSpace.serverTrust!) - ) - } -} diff --git a/Sources/MBNetworking/Networkable+PinningDelegate.swift b/Sources/MBNetworking/Networkable+PinningDelegate.swift new file mode 100644 index 0000000..dc3a546 --- /dev/null +++ b/Sources/MBNetworking/Networkable+PinningDelegate.swift @@ -0,0 +1,121 @@ +// +// Networkable+PinningDelegate.swift +// Networking +// +// Created by Rasid Ramazanov on 29.01.2020. +// Copyright © 2020 Mobven. All rights reserved. +// + +import Foundation +import Security + +protocol URLSessionPinningDelegateProtocol: URLSessionDelegate { + var certificatePaths: [String] { get set } +} + +enum URLSessionPinningComposer { + static func createDelegate() -> URLSessionPinningDelegateProtocol { + if #available(iOS 13.0, *) { + URLSessionPinningDelegateAsync() + } else { + URLSessionPinningDelegate() + } + } +} + +extension URLSessionPinningDelegateProtocol { + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + Session.shared.networkLogMonitoringDelegate?.logTask(task: task, didFinishCollecting: metrics) + } +} + +private final class URLSessionPinningDelegate: NSObject, URLSessionPinningDelegateProtocol { + var certificatePaths: [String] = [] + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let result = URLSessionPinningHelper.processAuthenticationChallenge( + challenge: challenge, + certificatePaths: certificatePaths + ) + completionHandler(result.0, result.1) + } +} + +@available(iOS 13.0, *) +private final class URLSessionPinningDelegateAsync: NSObject, URLSessionPinningDelegateProtocol { + var certificatePaths: [String] = [] + + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + URLSessionPinningHelper.processAuthenticationChallenge( + challenge: challenge, + certificatePaths: certificatePaths + ) + } +} + +private enum URLSessionPinningHelper { + static func getServerCertificate( + forChallenge challenge: URLAuthenticationChallenge + ) -> (data: CFData, trust: SecTrust)? { + var secresult = SecTrustResultType.invalid + guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let serverTrust = challenge.protectionSpace.serverTrust, + errSecSuccess == SecTrustEvaluate(serverTrust, &secresult), + let serverCertificate = SecTrustGetCertificateAtIndex(serverTrust, 0) + else { + return nil + } + + let serverCertificateData = SecCertificateCopyData(serverCertificate) + return (serverCertificateData, serverTrust) + } + + static func handleChallengeCommon( + certificatePaths: [String], + serverCertificate: (data: CFData, trust: SecTrust) + ) -> URLSession.AuthChallengeDisposition { + let serverPublicKeys = serverCertificate.trust.certificates.publicKeys + for certificatePath in certificatePaths { + if let localCertificateData = try? Data(contentsOf: URL(fileURLWithPath: certificatePath)) as CFData?, + let localCertificate = SecCertificateCreateWithData(nil, localCertificateData), + let localPublicKey = localCertificate.publicKey { + if serverPublicKeys.contains(localPublicKey) { + return .useCredential + } + } + } + + if certificatePaths.isEmpty { + return .performDefaultHandling + } else { + return .cancelAuthenticationChallenge + } + } + + static func processAuthenticationChallenge( + challenge: URLAuthenticationChallenge, + certificatePaths: [String] + ) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + guard let serverCertificate = getServerCertificate(forChallenge: challenge) else { + return (.cancelAuthenticationChallenge, nil) + } + + let disposition = handleChallengeCommon( + certificatePaths: certificatePaths, + serverCertificate: serverCertificate + ) + + if disposition == .useCredential { + return (disposition, URLCredential(trust: serverCertificate.trust)) + } else { + return (disposition, nil) + } + } +} diff --git a/Sources/MBNetworking/Networkable+URLRequest.swift b/Sources/MBNetworking/Networkable+URLRequest.swift index b90c9b2..4744ac9 100644 --- a/Sources/MBNetworking/Networkable+URLRequest.swift +++ b/Sources/MBNetworking/Networkable+URLRequest.swift @@ -10,11 +10,10 @@ import Foundation import MBErrorKit /// `Networkable` extension related to `URLRequest`'s. -extension Networkable { - +public extension Networkable { /** Returns GET or DELETE `URLRequest` with specified url and query item. - + - parameter url: `URL`. - parameter queryItems: Query items to be appended to the url, eg, pageSize: 10 will be appended to url as &pageSize=10. @@ -22,38 +21,46 @@ extension Networkable { - parameter httpMethod: HTTP method. (GET, DELETE) - returns: `URLRequest` with specified url and query item. */ - public func getRequest(url: URL, - queryItems: [String: String] = [:], - headers: [String: String] = [:], - httpMethod: RequestType = .GET) -> URLRequest { - //TODO: throw exception when an unexpected http method is encountered + func getRequest( + url: URL, + queryItems: [String: String] = [:], + headers: [String: String] = [:], + httpMethod: RequestType = .GET + ) -> URLRequest { + // TODO: throw exception when an unexpected http method is encountered let url = url.adding(parameters: queryItems) - var request = getRequest(with: url, - httpMethod: httpMethod, - headers: headers, - contentType: .json) + var request = getRequest( + with: url, + httpMethod: httpMethod, + headers: headers, + contentType: .json + ) request.timeoutInterval = Session.shared.timeout.request return request } - + /** Returns POST, PUT or DELETE `URLRequest` with specified url and encodable body object. - + - parameter url: `URL`. - parameter encodable: Any object confirming `Encodable` to be used in `URLRequest.httpBody`. - parameter headers: HTTP headers. - parameter httpMethod: HTTP method. (DELETE, POST, PUT) - returns: `URLRequest` with specified url and encodable body object. */ - public func getRequest(url: URL, - encodable data: T, - headers: [String: String] = [:], - httpMethod: RequestType = .POST) -> URLRequest { - //TODO: throw exception when an unexpected http method is encountered - var request = getRequest(with: url, - httpMethod: httpMethod, - headers: headers, - contentType: .json) + func getRequest( + url: URL, + encodable data: some Encodable, + headers: [String: String] = [:], + httpMethod: RequestType = .POST + ) -> URLRequest { + // TODO: throw exception when an unexpected http method is encountered + var request = getRequest( + with: url, + httpMethod: httpMethod, + headers: headers, + contentType: .json + ) do { request.httpBody = try JSONEncoder().encode(data) } catch { @@ -65,103 +72,119 @@ extension Networkable { request.timeoutInterval = Session.shared.timeout.request return request } - + /** Returns POST or PUT `URLRequest` with specified url and form item. - + - parameter url: `URL`. - parameter formItems: HashMap to be used in `URLRequest.httpBody`. - parameter headers: HTTP headers. - parameter httpMethod: HTTP method. (POST, PUT) - returns: `URLRequest` with specified url and form item. */ - public func getRequest(url: URL, - formItems: [String: String] = [:], - headers: [String: String] = [:], - httpMethod: RequestType = .POST) -> URLRequest { - //TODO: throw exception when an unexpected http method is encountered - let formData = formItems.map({ + func getRequest( + url: URL, + formItems: [String: String] = [:], + headers: [String: String] = [:], + httpMethod: RequestType = .POST + ) -> URLRequest { + // TODO: throw exception when an unexpected http method is encountered + let formData = formItems.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .nwURLQueryAllowed) ?? "")" - }).joined(separator: "&") - var request = getRequest(with: url, - httpMethod: httpMethod, - headers: headers, - contentType: .urlencoded) + }.joined(separator: "&") + var request = getRequest( + with: url, + httpMethod: httpMethod, + headers: headers, + contentType: .urlencoded + ) request.httpBody = formData.data(using: .utf8) request.timeoutInterval = Session.shared.timeout.request return request } - + /** Returns `URLRequest` for file upload - + - parameter url: `URL`. - parameter parameters: Parameters to be added to multipart `URLRequest.httpBody`. - parameter files: Files to be added to multipart `URLRequest.httpBody`. - parameter headers: HTTP headers. - returns: `URLRequest` with specified parameters. */ - public func uploadRequest(url: URL, - parameters: [String: String] = [:], files: [File] = [], - headers: [String: String] = [:]) -> URLRequest { + func uploadRequest( + url: URL, + parameters: [String: String] = [:], + files: [File] = [], + headers: [String: String] = [:] + ) -> URLRequest { let body = NSMutableData() let boundary = "Boundary-\(UUID().uuidString)" let boundaryPrefix = "--\(boundary)\r\n" - + for (key, value) in parameters { body.appendString(boundaryPrefix) body.appendString("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n") body.appendString("\(value)\r\n") } - + for file in files { body.appendString(boundaryPrefix) - body.appendString("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.fileNameWithExtension)\"\r\n") + body + .appendString( + "Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.fileNameWithExtension)\"\r\n" + ) body.appendString("Content-Type: \(file.mimeType)\r\n\r\n") body.append(file.data) body.appendString("\r\n") } body.appendString("--".appending(boundary.appending("--"))) - + let url = url - var request = getRequest(with: url, - httpMethod: .POST, - headers: headers, - contentType: .multipartFormData(boundary)) + var request = getRequest( + with: url, + httpMethod: .POST, + headers: headers, + contentType: .multipartFormData(boundary) + ) request.httpBody = body as Data request.timeoutInterval = Session.shared.timeout.request return request } - + /** Returns `URLRequest` with specified url and httpMehthod. - + - parameter url: `URL` of the request. - parameter httpMethod: HTTP method of the reuest, either GET or POST. - parameter headers: HTTP headers. - parameter contentType: Content-Type of the request. - returns: `URLRequest` with specified url and httpMehthod. */ - private func getRequest(with url: URL, httpMethod: RequestType, - headers: [String: String], - contentType: NetworkContentType) -> URLRequest { + private func getRequest( + with url: URL, + httpMethod: RequestType, + headers: [String: String], + contentType: NetworkContentType + ) -> URLRequest { var request = URLRequest(url: url) request.httpMethod = httpMethod.rawValue - + request.allHTTPHeaderFields = getHeaders(headers, contentType: contentType) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - + return request } - + /// Returns default headers for used `URLRequest`s. - private func getHeaders(_ headers: [String: String], - contentType: NetworkContentType) -> [String: String] { + private func getHeaders( + _ headers: [String: String], + contentType: NetworkContentType + ) -> [String: String] { var heads = headers heads["Content-Type"] = contentType.rawValue return heads } - } /// "Content-Type" values for network requests. @@ -170,19 +193,18 @@ public enum NetworkContentType { case json case urlencoded case multipartFormData(String) - + var rawValue: String { switch self { - case .json: return "application/json" - case .urlencoded: return "application/x-www-form-urlencoded" - case .multipartFormData(let boundary): return "multipart/form-data; boundary=\(boundary)" + case .json: "application/json" + case .urlencoded: "application/x-www-form-urlencoded" + case let .multipartFormData(boundary): "multipart/form-data; boundary=\(boundary)" } } } /// Request types to be passed as `URLRequest.httpMethod`. public enum RequestType: String { - /// HTTP GET request case GET /// HTTP POST request diff --git a/Sources/MBNetworking/Networkable+UntrustedDelegate.swift b/Sources/MBNetworking/Networkable+UntrustedDelegate.swift new file mode 100644 index 0000000..61078ac --- /dev/null +++ b/Sources/MBNetworking/Networkable+UntrustedDelegate.swift @@ -0,0 +1,38 @@ +// +// Networkable+UntrustedDelegate.swift +// Networking +// +// Created by Umut Can ARDUÇ on 9.08.2024. +// Copyright © 2024 Mobven. All rights reserved. +// + +import Foundation + +enum UntrustedURLSessionComposer { + static func createDelegate() -> URLSessionDelegate { + if #available(iOS 13.0, *) { + UntrustedURLSessionDelegateAsync() + } else { + UntrustedURLSessionDelegate() + } + } +} + +@available(iOS 13.0, *) class UntrustedURLSessionDelegateAsync: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + (.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!)) + } +} + +class UntrustedURLSessionDelegate: NSObject, URLSessionDelegate { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + completionHandler(.useCredential, URLCredential(trust: challenge.protectionSpace.serverTrust!)) + } +} diff --git a/Sources/MBNetworking/Networkable.swift b/Sources/MBNetworking/Networkable.swift index 3d68af4..95d7e82 100644 --- a/Sources/MBNetworking/Networkable.swift +++ b/Sources/MBNetworking/Networkable.swift @@ -1,5 +1,5 @@ // -// NetworkEndpoint.swift +// Networkable.swift // Networking // // Created by Rasid Ramazanov on 25.11.2019. diff --git a/Sources/MBNetworking/NetworkableConfigs.swift b/Sources/MBNetworking/NetworkableConfigs.swift index 9a672bf..ffb967f 100644 --- a/Sources/MBNetworking/NetworkableConfigs.swift +++ b/Sources/MBNetworking/NetworkableConfigs.swift @@ -42,7 +42,7 @@ public enum NetworkableConfigs { public func set(configuration: URLSessionConfiguration) { Session.shared.configuration = configuration } - + /// Sets `NetworkLogMonitoringDelegate` that used for network log monitoring. /// Default value is `nil`. /// - Parameter networkLogMonitoringDelegate: NetworkLogMonitoringDelegate. diff --git a/Sources/MBNetworking/Session.swift b/Sources/MBNetworking/Session.swift index ff0d96d..84c563a 100644 --- a/Sources/MBNetworking/Session.swift +++ b/Sources/MBNetworking/Session.swift @@ -11,7 +11,7 @@ import Foundation final class Session { static var instance: Session? static var shared: Session { - guard let instance = instance else { + guard let instance else { self.instance = Session() return self.instance! } @@ -34,7 +34,7 @@ final class Session { /// SSL certificate paths of `URLSessionDelegate`. var certificatePaths: [String] = [] { didSet { - (delegate as? URLSessionPinningDelegate)?.certificatePaths = certificatePaths + (delegate as? URLSessionPinningDelegateProtocol)?.certificatePaths = certificatePaths } } @@ -49,14 +49,14 @@ final class Session { ) } } - + /// `NetworkLogMonitoringDelegate` that used for network log monitoring. /// Default value is `nil`. var networkLogMonitoringDelegate: NetworkLogMonitoringDelegate? /// Configures networking to trust session authentication challenge, even if the certificate is not trusted. func setServerTrustedURLAuthenticationChallenge() { - delegate = UntrustedURLSessionDelegate() + delegate = UntrustedURLSessionComposer.createDelegate() session = URLSession( configuration: configuration, delegate: delegate, @@ -65,7 +65,7 @@ final class Session { } required init() { - delegate = URLSessionPinningDelegate() + delegate = URLSessionPinningComposer.createDelegate() session = URLSession( configuration: configuration, delegate: delegate, diff --git a/Sources/MBNetworking/StubURLProtocol/Result.swift b/Sources/MBNetworking/StubURLProtocol/Result.swift index 9ed5b0f..0b8b45d 100644 --- a/Sources/MBNetworking/StubURLProtocol/Result.swift +++ b/Sources/MBNetworking/StubURLProtocol/Result.swift @@ -8,10 +8,8 @@ import Foundation -extension StubURLProtocol { - - public enum Result { - +public extension StubURLProtocol { + enum Result { /// Successfull result with specified data /// You can use `StubURLProtocol.Result.getData()` to read mock data from bundle, easily and inline. case success(Data) @@ -21,13 +19,10 @@ extension StubURLProtocol { /// Failure with the specified status code. /// The actual result will of `Networkable.fetch` will be `NetworkingError.httpError`. case failureStatusCode(Int) - } - } public extension StubURLProtocol.Result { - /// Prepares `StubURLProtocol.Result.success(Data)` from specified Bundle path. /// - Parameter url: Bundle URL for the specifed resource. Can be received from `url(forResource:,ofType:)`. /// - Returns: Returns `StubURLProtocol.Result.success(Data)` with data from specified file url. @@ -50,5 +45,4 @@ public extension StubURLProtocol.Result { } return .success(data) } - } diff --git a/Sources/MBNetworking/StubURLProtocol/StubURLProtocol.swift b/Sources/MBNetworking/StubURLProtocol/StubURLProtocol.swift index 9e360d0..a3f4914 100644 --- a/Sources/MBNetworking/StubURLProtocol/StubURLProtocol.swift +++ b/Sources/MBNetworking/StubURLProtocol/StubURLProtocol.swift @@ -12,7 +12,6 @@ import MBErrorKit /// URLProtocol for simplifying unit tests by acting man-in-the-middle on for the session. /// It's configured to work only with test targets. It won't work if there's no test process in progress. public final class StubURLProtocol: URLProtocol { - /// Result of the request, which is going to happen. public static var result: Result? { didSet { @@ -30,50 +29,61 @@ public final class StubURLProtocol: URLProtocol { public static var delay: TimeInterval = 0 static var isEnabled: Bool { - return result != nil + result != nil } - } -extension StubURLProtocol { - - public override class func canInit(with request: URLRequest) -> Bool { - return isEnabled +public extension StubURLProtocol { + override class func canInit(with request: URLRequest) -> Bool { + isEnabled } - public override class func canInit(with task: URLSessionTask) -> Bool { - return isEnabled + override class func canInit(with task: URLSessionTask) -> Bool { + isEnabled } - public override class func canonicalRequest(for request: URLRequest) -> URLRequest { - return request + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + request } - public override func startLoading() { + override func startLoading() { Timer.scheduledTimer(withTimeInterval: StubURLProtocol.delay, repeats: false) { [weak self] _ in - guard let self = self else { return } + guard let self else { return } guard let result = StubURLProtocol.result else { - self.client?.urlProtocolDidFinishLoading(self) + client?.urlProtocolDidFinishLoading(self) return } switch result { case let .success(data): - self.client?.urlProtocol(self, didLoad: data) + client?.urlProtocol(self, didLoad: data) + + if let url = request.url, + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil) { + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .allowed) + } case let .failure(error): - self.client?.urlProtocol(self, didFailWithError: error) + client?.urlProtocol(self, didFailWithError: error) case let .failureStatusCode(statusCode): - if let url = self.request.url, - let response = HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil) { - self.client?.urlProtocol(self, cachedResponseIsValid: CachedURLResponse(response: response, data: Data())) + if let url = request.url, + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) { + client?.urlProtocol( + self, + cachedResponseIsValid: CachedURLResponse(response: response, data: Data()) + ) } } - self.client?.urlProtocolDidFinishLoading(self) + + client?.urlProtocolDidFinishLoading(self) } } - public override func stopLoading() { + override func stopLoading() { // Nothing to handle } - } diff --git a/Sources/MBNetworking/UploadFile.swift b/Sources/MBNetworking/UploadFile.swift index 77ea854..c1e2f45 100644 --- a/Sources/MBNetworking/UploadFile.swift +++ b/Sources/MBNetworking/UploadFile.swift @@ -10,7 +10,6 @@ import Foundation /// Multipart body file public struct File { - /// Key of multipart form data. public var name: String /// File name @@ -23,7 +22,7 @@ public struct File { public var data: Data var fileNameWithExtension: String { - var fileName = self.fileName + var fileName = fileName if !fileExtension.isEmpty { fileName.append(".") fileName.append(fileExtension) @@ -51,5 +50,4 @@ public struct File { self.mimeType = mimeType self.data = data } - } diff --git a/Tests/MBNetworkingTests/NetworkablePerformanceAsyncTests.swift b/Tests/MBNetworkingTests/NetworkablePerformanceAsyncTests.swift new file mode 100644 index 0000000..89e63e6 --- /dev/null +++ b/Tests/MBNetworkingTests/NetworkablePerformanceAsyncTests.swift @@ -0,0 +1,134 @@ +// +// NetworkablePerformanceAsyncTests.swift +// Networking +// +// Created by Umut Can ARDUÇ on 9.08.2024. +// Copyright © 2024 Mobven. All rights reserved. +// + +#if canImport(UIKit) + import Foundation + import UIKit + import XCTest + @testable import MBErrorKit + @testable import MBNetworking + @testable import MobKitCore + + class NetworkablePerformanceAsyncTests: XCTestCase { + var imageView: UIImageView = .init() + + override func setUp() { + MobKit.isDeveloperModeOn = true + NetworkableConfigs.default.set(configuration: URLSessionConfiguration.ephemeral) + } + + func testWhenMultipleDownloadCommandCalled() async throws { + let expectation = XCTestExpectation(description: "wait for image") + + for i in 0 ..< 100 { + try await downloadImage(index: i) + expectation.fulfill() + } + + await fulfillment(of: [expectation], timeout: 10) + } + + private func downloadImage(index: Int) async throws { + try await imageView.downloadImageFrom(index: index) + } + } + + extension UIImageView { + func downloadImageFrom(index: Int) async throws { + if let savedImage = FileIOManager.readFile("\(index)"), + let image = UIImage(data: savedImage) { + self.image = image + return + } + + let data = try await getProfilePhoto() + + image = UIImage(named: "ky_avatar") + let image = UIImage(data: data) + self.image = image + FileIOManager.writeFile("\(index)", content: data) + } + + private func getProfilePhoto() async throws -> Data { + try await API.getProfilePhoto.fetch(Data.self) + } + } + + enum API: Networkable { + case getProfilePhoto + + var request: URLRequest { + URLRequest(url: URL(forceString: "https://picsum.photos/200/300")) + } + } + + enum UIImageManager { + static func convertImageToBase64String(img: UIImage) -> String { + img.pngData()?.base64EncodedString() ?? "" + } + + static func convertBase64StringToImage(data: Data) -> UIImage? { + UIImage(data: data) + } + } + + enum FileIOManager { + private static let localDirectory = "kutup_pp" + + @discardableResult static func writeFile(_ fileName: String, content: Data) -> Bool { + guard let directory = getFileDirectory() else { + return false + } + guard createDirectoryIfNeeded(directory) else { + return false + } + let fileURL = directory.appendingPathComponent(fileName) + do { + try content.write(to: fileURL, options: .atomic) + return true + } catch { + return false + } + } + + private static func createDirectoryIfNeeded(_ directory: URL) -> Bool { + guard !FileManager.default.fileExists(atPath: directory.absoluteString) else { + // Directory exists, no need to recreate it. + return true + } + do { + try FileManager.default.createDirectory( + at: directory, withIntermediateDirectories: true, attributes: nil + ) + return true + } catch { + print(error.localizedDescription) + return false + } + } + + static func readFile(_ fileName: String) -> Data? { + guard let directory = getFileDirectory() else { + return nil + } + let fileURL = directory.appendingPathComponent(fileName) + do { + return try Data(contentsOf: fileURL) + } catch { + return nil + } + } + + private static func getFileDirectory() -> URL? { + guard let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + return nil + } + return directory.appendingPathComponent(localDirectory, isDirectory: true) + } + } +#endif diff --git a/Tests/MBNetworkingTests/NetworkablePerformanceTests.swift b/Tests/MBNetworkingTests/NetworkablePerformanceTests.swift index 63ed605..90b27d0 100644 --- a/Tests/MBNetworkingTests/NetworkablePerformanceTests.swift +++ b/Tests/MBNetworkingTests/NetworkablePerformanceTests.swift @@ -23,11 +23,11 @@ func testWhenMultipleDownloadCommandCalled() { let expectation = XCTestExpectation(description: "wait for image") - for i in 0 ..< 10000 { + for i in 0 ..< 1000 { downloadImage(index: i) } - XCTWaiter().wait(for: [expectation], timeout: 100) - Timer.scheduledTimer(withTimeInterval: 100, repeats: false) { _ in + XCTWaiter().wait(for: [expectation], timeout: 10) + Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in expectation.fulfill() } } @@ -50,7 +50,7 @@ let image = UIImage(data: data) self.image = image FileIOManager.writeFile("\(index)", content: data) - case let .failure(error): + case .failure: return } } @@ -62,77 +62,4 @@ API.getProfilePhoto.fetch(Data.self, completion: completion) } } - - enum API: Networkable { - case getProfilePhoto - - var request: URLRequest { - URLRequest(url: URL(forceString: "https://picsum.photos/200/300")) - } - } - - enum UIImageManager { - static func convertImageToBase64String(img: UIImage) -> String { - img.pngData()?.base64EncodedString() ?? "" - } - - static func convertBase64StringToImage(data: Data) -> UIImage? { - UIImage(data: data) - } - } - - enum FileIOManager { - private static let localDirectory = "kutup_pp" - - @discardableResult static func writeFile(_ fileName: String, content: Data) -> Bool { - guard let directory = getFileDirectory() else { - return false - } - guard createDirectoryIfNeeded(directory) else { - return false - } - let fileURL = directory.appendingPathComponent(fileName) - do { - try content.write(to: fileURL, options: .atomic) - return true - } catch { - return false - } - } - - private static func createDirectoryIfNeeded(_ directory: URL) -> Bool { - guard !FileManager.default.fileExists(atPath: directory.absoluteString) else { - // Directory exists, no need to recreate it. - return true - } - do { - try FileManager.default.createDirectory( - at: directory, withIntermediateDirectories: true, attributes: nil - ) - return true - } catch { - print(error.localizedDescription) - return false - } - } - - static func readFile(_ fileName: String) -> Data? { - guard let directory = getFileDirectory() else { - return nil - } - let fileURL = directory.appendingPathComponent(fileName) - do { - return try Data(contentsOf: fileURL) - } catch { - return nil - } - } - - private static func getFileDirectory() -> URL? { - guard let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { - return nil - } - return directory.appendingPathComponent(localDirectory, isDirectory: true) - } - } #endif diff --git a/Tests/MBNetworkingTests/NetworkableTasksTests.swift b/Tests/MBNetworkingTests/NetworkableTasksTests.swift index a7584d7..51866d6 100644 --- a/Tests/MBNetworkingTests/NetworkableTasksTests.swift +++ b/Tests/MBNetworkingTests/NetworkableTasksTests.swift @@ -24,15 +24,19 @@ class NetworkableTasksTests: XCTestCase { } func testSessionQueueHasDataTask_WhenFetchCalled() { - makeACall() + let expectation = XCTestExpectation(description: "waiting for image") + makeACall(expectation) XCTAssertEqual(Session.shared.tasksInProgress.count, 1) + wait(for: [expectation], timeout: 1) } func testSessionQueueHasDataTaskWhenFetchCalledMultipleTimes() { + let expectation = XCTestExpectation(description: "waiting for image") for _ in 0 ... 3 { - makeACall() + makeACall(expectation) } XCTAssertEqual(Session.shared.tasksInProgress.count, 4) + wait(for: [expectation], timeout: 1) } func testSessionQueueHasRemovedDataTask_WhenTaskIsFinished() { diff --git a/Tests/MBNetworkingTests/NetworkingAsyncTests.swift b/Tests/MBNetworkingTests/NetworkingAsyncTests.swift new file mode 100644 index 0000000..9afac74 --- /dev/null +++ b/Tests/MBNetworkingTests/NetworkingAsyncTests.swift @@ -0,0 +1,38 @@ +// +// NetworkingAsyncTests.swift +// MBNetworking +// +// Created by Umut Can ARDUÇ on 9.08.2024. +// Copyright © 2021 Mobven. All rights reserved. +// + +import XCTest +@testable import MBNetworking +@testable import MobKitCore + +#if canImport(UIKit) + class NetworkingAsyncTests: XCTestCase { + override func setUp() { + MobKit.isDeveloperModeOn = true + StubURLProtocol.delay = .zero + } + + func testDataDownloadAsync() async throws { + StubURLProtocol + .result = .getData(from: Bundle.module.url(forResource: "imageDownload", withExtension: "jpg")) + var image: UIImage? + + let result = try await Download.data( + url: URL(forceString: "https://miro.medium.com/max/1400/1*2AodTHXf8giVb4QoIBGSww.png") + ).fetch(Data.self) + + if case let .success(actualData) = StubURLProtocol.result { + XCTAssertEqual(result, actualData) + } + + image = UIImage(data: result) + + XCTAssertNotNil(image) + } + } +#endif diff --git a/Tests/MBNetworkingTests/NetworkingTests.swift b/Tests/MBNetworkingTests/NetworkingTests.swift index eb420b6..1ff5e37 100644 --- a/Tests/MBNetworkingTests/NetworkingTests.swift +++ b/Tests/MBNetworkingTests/NetworkingTests.swift @@ -18,16 +18,27 @@ import XCTest } func testDataDownload() { - StubURLProtocol - .result = .getData(from: Bundle.module.url(forResource: "imageDownload", withExtension: "jpg")) + let expectation = expectation(description: "Data download should succeed and produce a valid image") + StubURLProtocol.result = .getData(from: Bundle.module.url( + forResource: "imageDownload", + withExtension: "jpg" + )) var image: UIImage? + Download.data( url: URL(forceString: "https://miro.medium.com/max/1400/1*2AodTHXf8giVb4QoIBGSww.png") ).fetch(Data.self) { result in if case let .success(data) = result { + if case let .success(actualData) = StubURLProtocol.result { + XCTAssertEqual(data, actualData) + } + image = UIImage(data: data) } + expectation.fulfill() } + waitForExpectations(timeout: 5, handler: nil) + XCTAssertNotNil(image) } } @@ -39,7 +50,7 @@ enum Download: Networkable { var request: URLRequest { switch self { case let .data(url): - return getRequest(url: url, queryItems: [:]) + getRequest(url: url, queryItems: [:]) } } } diff --git a/Tests/MBNetworkingTests/ResultTests/ResultTestAPI.swift b/Tests/MBNetworkingTests/ResultTests/ResultTestAPI.swift index 67dfdd5..38c4cbd 100644 --- a/Tests/MBNetworkingTests/ResultTests/ResultTestAPI.swift +++ b/Tests/MBNetworkingTests/ResultTests/ResultTestAPI.swift @@ -10,14 +10,12 @@ import Foundation @testable import MBNetworking enum ResultTestAPI: Networkable { - case fetch case underlyingError case httpError - + var request: URLRequest { switch self { - case .fetch: let url = URL(forceString: "https://itunes.apple.com/search") return getRequest(url: url, queryItems: ["media": "music"]) @@ -28,7 +26,5 @@ enum ResultTestAPI: Networkable { let url = URL(forceString: "https://itunes.apple.com/search") return getRequest(url: url, queryItems: ["media": "0"]) } - } - } diff --git a/Tests/MBNetworkingTests/SSLPinningTests.swift b/Tests/MBNetworkingTests/SSLPinningTests.swift index b5b8200..124fd8c 100644 --- a/Tests/MBNetworkingTests/SSLPinningTests.swift +++ b/Tests/MBNetworkingTests/SSLPinningTests.swift @@ -63,7 +63,7 @@ class SSLPinningTests: XCTestCase { public var request: URLRequest { switch self { case let .sendToken(request): - return getRequest( + getRequest( url: URL(forceString: "https://api.macfit.com.tr/api/auth/sendToken"), encodable: request, headers: API.getHeaders() diff --git a/Tests/MBNetworkingTests/StubURLProtocolTests.swift b/Tests/MBNetworkingTests/StubURLProtocolTests.swift index ace58d2..98a5428 100644 --- a/Tests/MBNetworkingTests/StubURLProtocolTests.swift +++ b/Tests/MBNetworkingTests/StubURLProtocolTests.swift @@ -49,20 +49,26 @@ class StubURLProtocolTests: XCTestCase { } expectation.fulfill() } - wait(for: [expectation], timeout: StubURLProtocol.delay) + waitForExpectations(timeout: 5, handler: nil) XCTAssertEqual(string, "some\n") } func test_When_StubProtocolFetchesJSON() { StubURLProtocol.result = .getData(from: Bundle.module.url(forResource: "results", withExtension: "json")) var response: DecodableTrue? + let expectation = expectation(description: "wait for delay") + Download.data( url: URL(forceString: "https://miro.medium.com/max/1400/1*2AodTHXf8giVb4QoIBGSww.png") ).fetch(DecodableTrue.self) { result in if case let .success(resp) = result { response = resp } + + expectation.fulfill() } + waitForExpectations(timeout: 5, handler: nil) + // The image in the link is 200x150 size. XCTAssertNotNil(response) XCTAssertEqual(response?.resultCount, 0) @@ -73,13 +79,19 @@ class StubURLProtocolTests: XCTestCase { StubURLProtocol .result = .getData(from: Bundle.module.url(forResource: "imageDownload", withExtension: "jpg")) var image: UIImage? + let expectation = expectation(description: "wait for delay") + Download.data( url: URL(forceString: "https://miro.medium.com/max/1400/1*2AodTHXf8giVb4QoIBGSww.png") ).fetch(Data.self) { result in if case let .success(data) = result { image = UIImage(data: data) } + + expectation.fulfill() } + waitForExpectations(timeout: 5, handler: nil) + // The image in the link is 200x150 size. XCTAssertNotNil(image) XCTAssertEqual(image?.size.width, 200) @@ -97,7 +109,8 @@ class StubURLProtocolTests: XCTestCase { } expectation.fulfill() } - wait(for: [expectation], timeout: 1) + waitForExpectations(timeout: 5, handler: nil) + // The real image in the link is 1400x637 size. XCTAssertNotNil(image) XCTAssertEqual(image?.size.width, 1400)