├── .DS_Store ├── Sources └── EZNetworking │ ├── Other │ ├── EmptyResponse.swift │ ├── Extensions │ │ ├── String-ext.swift │ │ └── Data-ext.swift │ ├── WebSocketTaskProtocol.swift │ └── URLSessionTaskProtocol.swift │ ├── Util │ ├── Uploader │ │ ├── Helpers │ │ │ ├── UploadStreamEvent.swift │ │ │ ├── UploadTypealias.swift │ │ │ └── DefaultUploadTaskInterceptor.swift │ │ ├── DataUploader │ │ │ ├── Protocols │ │ │ │ └── DataUploadable.swift │ │ │ └── DataUploader.swift │ │ └── FileUploader │ │ │ ├── Protocols │ │ │ └── FileUploadable.swift │ │ │ └── FileUploader.swift │ ├── Performers │ │ ├── Protocols │ │ │ └── RequestPerformable.swift │ │ └── RequestPerformer.swift │ ├── Downloader │ │ ├── Protocols │ │ │ └── FileDownloadable.swift │ │ ├── Helpers │ │ │ └── DefaultDownloadTaskInterceptor.swift │ │ └── FileDownloader.swift │ └── WebSocket │ │ └── Helpers │ │ ├── DefaultWebSocketTaskInterceptor.swift │ │ └── WebSocketConnectionState.swift │ ├── HTTP │ ├── Body │ │ ├── HTTPBody.swift │ │ └── MultipartFormData │ │ │ ├── MultipartFormPart.swift │ │ │ └── MultipartFormData.swift │ ├── Parameters │ │ └── HTTPParameters.swift │ ├── Methods │ │ └── HTTPMethod.swift │ └── Headers │ │ ├── HTTPHeader.swift │ │ ├── MimeType.swift │ │ └── AuthorizationType.swift │ ├── Interceptors │ ├── Protocols │ │ ├── MetricsInterceptor.swift │ │ ├── CacheInterceptor.swift │ │ ├── RedirectInterceptor.swift │ │ ├── UploadTaskInterceptor.swift │ │ ├── TaskLifecycleInterceptor.swift │ │ ├── AuthenticationInterceptor.swift │ │ ├── StreamTaskInterceptor.swift │ │ ├── DownloadTaskInterceptor.swift │ │ ├── DataTaskInterceptor.swift │ │ └── WebSocketTaskInterceptor.swift │ ├── SessionDelegate+Extensions │ │ ├── SessionDelegate+URLSessionDelegate.swift │ │ ├── SessionDelegate+URLSessionWebSocketDelegate.swift │ │ ├── SessionDelegate+URLSessionStreamDelegate.swift │ │ ├── SessionDelegate+URLSessionDownloadDelegate.swift │ │ ├── SessionDelegate+URLSessionDataDelegate.swift │ │ └── SessionDelegate+URLSessionTaskDelegate.swift │ └── SessionDelegate.swift │ ├── Error │ ├── HTTPError │ │ ├── StatusCodeTypes │ │ │ ├── HTTPInformationalStatus.swift │ │ │ ├── HTTPRedirectionStatus.swift │ │ │ ├── HTTPSuccessStatus.swift │ │ │ ├── HTTPServerErrorStatus.swift │ │ │ └── HTTPClientErrorStatus.swift │ │ └── HTTPError.swift │ ├── NetworkingError.swift │ ├── InternalError │ │ └── InternalError.swift │ └── WebSocketError │ │ └── WebSocketError.swift │ ├── Encoders │ ├── HTTPHeaderEncoder.swift │ └── HTTPParameterEncoder.swift │ ├── Decoding │ └── RequestDecoder.swift │ ├── Request │ ├── RequestFactory.swift │ ├── Request.swift │ └── RequestBuilder.swift │ └── Validator │ └── ResponseValidator.swift ├── Tests └── EZNetworkingTests │ ├── Mocks │ ├── Mock-Codeable.swift │ ├── Mock-JSON.swift │ └── MockURLResponseValidator.swift │ ├── Util │ ├── Performers │ │ ├── Mocks │ │ │ ├── MockURLSessionDataTask.swift │ │ │ └── MockRequestPerformerURLSession.swift │ │ └── RequestPerformable_asyncAwait_Tests.swift │ ├── Uploader │ │ ├── Mocks │ │ │ ├── MockURLSessionUploadTask.swift │ │ │ └── MockUploadTaskInterceptor.swift │ │ ├── Helpers │ │ │ └── DefaultUploadTaskInterceptor.swift │ │ ├── DataUploader │ │ │ └── Mocks │ │ │ │ └── MockDataUploaderURLSession.swift │ │ └── FileUploader │ │ │ └── Mocks │ │ │ └── MockFileUploaderURLSession.swift │ ├── Downloader │ │ ├── Mocks │ │ │ ├── MockURLSessionDownloadTask.swift │ │ │ ├── FileDownloader_MockDownloadTaskInterceptor.swift │ │ │ └── MockFileDownloaderURLSession.swift │ │ └── Helpers │ │ │ └── DefaultDownloadTaskInterceptorTests.swift │ └── WebSocket │ │ ├── Mocks │ │ ├── MockWebSockerURLSession.swift │ │ ├── MockWebSocketTaskInterceptor.swift │ │ └── MockURLSessionWebSocketTask.swift │ │ └── Helpers │ │ ├── WebSocketConnectionStateTests.swift │ │ └── DefaultWebSocketTaskInterceptorTests.swift │ ├── HTTP │ ├── Parameters │ │ └── HTTPParameterTests.swift │ ├── Body │ │ ├── HTTPBodyTests.swift │ │ └── MultipartFormData │ │ │ └── MultipartFormPartTests.swift │ ├── Headers │ │ └── HTTPHeaderTests.swift │ └── Methods │ │ └── HTTPMethodTests.swift │ ├── Error │ ├── HTTPError │ │ ├── StatusCodeTypes │ │ │ ├── HTTPInformationalStatusTests.swift │ │ │ ├── HTTPRedirectionStatusTests.swift │ │ │ ├── HTTPSuccessStatusTests.swift │ │ │ ├── HTTPServerErrorStatusTests.swift │ │ │ └── HTTPClientErrorStatusTests.swift │ │ └── HTTPErrorTests.swift │ ├── InternalError │ │ └── InternalErrorTests.swift │ └── WebSocketError │ │ └── WebSocketErrorTests.swift │ ├── Encoders │ ├── HTTPParameterEncoderTests.swift │ └── HTTPHeaderEncoderTests.swift │ ├── Decoding │ └── RequestDecoderTests.swift │ ├── Other │ └── Extensions │ │ └── String-extTests.swift │ ├── Request │ ├── RequestBuilderTests.swift │ ├── RequestFactoryTests.swift │ └── RequestTests.swift │ ├── Interceptors │ └── SessoionDelegate+Extensions+Tests │ │ ├── SessionDelegate+URLSessionDownloadDelegate+Tests.swift │ │ ├── SessionDelegate+URLSessionDelegate+Tests.swift │ │ ├── SessionDelegate+URLSessionStreamDelegate+Tests.swift │ │ ├── SessionDelegate+URLSessionWebSocketDelegate+Tests.swift │ │ ├── SessionDelegate+URLSessionDataDelegate+Tests.swift │ │ └── SessionDelegate+URLSessionTaskDelegate+Tests.swift │ └── Validator │ └── ResponseValidatorTests.swift ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcuserdata │ └── albertodominguez.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── .gitignore ├── Package.swift └── LICENSE /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aldo10012/EZNetworking/HEAD/.DS_Store -------------------------------------------------------------------------------- /Sources/EZNetworking/Other/EmptyResponse.swift: -------------------------------------------------------------------------------- 1 | public struct EmptyResponse: Decodable { 2 | public init() { } 3 | } 4 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Mocks/Mock-Codeable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Person: Codable { 4 | var name: String 5 | var age: Int 6 | } 7 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/Helpers/UploadStreamEvent.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum UploadStreamEvent { 4 | case progress(Double) 5 | case success(Data) 6 | case failure(NetworkingError) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/Helpers/UploadTypealias.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias UploadProgressHandler = (Double) -> Void 4 | public typealias UploadCompletionHandler = (Result) -> Void 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore a specific file 2 | 3 | .swiftpm/xcode/package.xcworkspace/xcuserdata/albertodominguez.xcuserdatad/UserInterfaceState.xcuserstate 4 | 5 | .swiftpm/xcode/xcuserdata/albertodominguez.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist 6 | 7 | .build/ -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Body/HTTPBody.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public typealias HTTPBody = DataConvertible 4 | 5 | public protocol DataConvertible { 6 | func toData() -> Data? 7 | } 8 | 9 | extension Data: DataConvertible { 10 | public func toData() -> Data? { self } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Other/Extensions/String-ext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension String { 4 | /// generate random boundary for multi-part-form data 5 | public static func getRandomMultiPartFormBoundary() -> String { 6 | return "EZNetworking.Boundary.\(UUID().uuidString)" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Parameters/HTTPParameters.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct HTTPParameter: Equatable { 4 | let key: String 5 | let value: String 6 | 7 | public init(key: String, value: String) { 8 | self.key = key 9 | self.value = value 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/MetricsInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting URL metrics collection. 4 | public protocol MetricsInterceptor: AnyObject { 5 | /// Intercepts metrics after task completion. 6 | func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/CacheInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting URL cache operations 4 | public protocol CacheInterceptor: AnyObject { 5 | /// Intercepts cache responses before they are cached for a specific data task. 6 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse) async -> CachedURLResponse? 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/RedirectInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting URL redirect operations. 4 | public protocol RedirectInterceptor: AnyObject { 5 | /// Intercepts URL redirection before the request is performed. 6 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? 7 | } 8 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/UploadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting upload tasks specifically. 4 | public protocol UploadTaskInterceptor: AnyObject { 5 | /// Track the progress of the upload process 6 | var progress: (Double) -> Void { get set } 7 | 8 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/StatusCodeTypes/HTTPInformationalStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPInformationalStatus: Error { 4 | static func description(from statusCode: Int) -> String { 5 | return HTTPInformationalStatus.descriptions[statusCode] ?? "Unknown Informational Status" 6 | } 7 | 8 | private static let descriptions: [Int: String] = [ 9 | 100: "Continue", 10 | 101: "Switching Protocols", 11 | 102: "Processing" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Performers/Mocks/MockURLSessionDataTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockURLSessionDataTask: URLSessionDataTask { 5 | private let closure: () -> Void 6 | var didCancel: Bool = false 7 | 8 | init(closure: @escaping () -> Void) { 9 | self.closure = closure 10 | } 11 | 12 | override func resume() { 13 | closure() 14 | } 15 | 16 | override func cancel() { 17 | didCancel = true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Uploader/Mocks/MockURLSessionUploadTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockURLSessionUploadTask: URLSessionUploadTask { 5 | private let closure: () -> Void 6 | var didCancel: Bool = false 7 | 8 | init(closure: @escaping () -> Void) { 9 | self.closure = closure 10 | } 11 | 12 | override func resume() { 13 | closure() 14 | } 15 | 16 | override func cancel() { 17 | didCancel = true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Downloader/Mocks/MockURLSessionDownloadTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockURLSessionDownloadTask: URLSessionDownloadTask { 5 | private let closure: () -> Void 6 | var didCancel: Bool = false 7 | 8 | init(closure: @escaping () -> Void) { 9 | self.closure = closure 10 | } 11 | 12 | override func resume() { 13 | closure() 14 | } 15 | 16 | override func cancel() { 17 | didCancel = true 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Encoders/HTTPHeaderEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol HTTPHeaderEncoder { 4 | func encodeHeaders(for urlRequest: inout URLRequest, with headers: [HTTPHeader]) 5 | } 6 | 7 | public struct HTTPHeaderEncoderImpl: HTTPHeaderEncoder { 8 | public init() {} 9 | 10 | public func encodeHeaders(for urlRequest: inout URLRequest, with headers: [HTTPHeader]) { 11 | for header in headers { 12 | urlRequest.setValue(header.value, forHTTPHeaderField: header.key) 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Performers/Protocols/RequestPerformable.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public protocol RequestPerformable { 5 | func perform(request: Request, decodeTo decodableObject: T.Type) async throws -> T 6 | func performTask(request: Request, decodeTo decodableObject: T.Type, completion: @escaping((Result) -> Void)) -> URLSessionDataTask? 7 | func performPublisher(request: Request, decodeTo decodableObject: T.Type) -> AnyPublisher 8 | } 9 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/HTTP/Parameters/HTTPParameterTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPParameter") 5 | final class HTTPParameterTests { 6 | 7 | @Test("test HTTPParameter .key and .value") 8 | func testHTTPParameterKeyAndValue() { 9 | let key = "param_key" 10 | let value = "param_value" 11 | let parameter = HTTPParameter(key: key, value: value) 12 | 13 | #expect(parameter.key == key) 14 | #expect(parameter.value == value) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Decoding/RequestDecoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RequestDecodable { 4 | func decode(_ type: T.Type, from data: Data) throws -> T 5 | } 6 | 7 | public struct RequestDecoder: RequestDecodable { 8 | public init() {} 9 | public func decode(_ type: T.Type, from data: Data) throws -> T { 10 | do { 11 | return try JSONDecoder().decode(T.self, from: data) 12 | } catch { 13 | throw NetworkingError.internalError(.couldNotParse) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Uploader/Mocks/MockUploadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockUploadTaskInterceptor: UploadTaskInterceptor { 5 | var progress: (Double) -> Void 6 | init(progress: @escaping (Double) -> Void) { 7 | self.progress = progress 8 | } 9 | 10 | var didCallDidSendBodyData = false 11 | 12 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { 13 | didCallDidSendBodyData = true 14 | progress(1) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/HTTP/Body/HTTPBodyTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test HTTPBody") 6 | class HTTPBodyTests { 7 | 8 | @Test("test Data is HTTPBody") 9 | func testDataIsHTTPBody() { 10 | #expect(Data() is HTTPBody) 11 | } 12 | 13 | @Test("test Data is DataConvertible") 14 | func testDataIsDataConvertible() { 15 | #expect(Data() is DataConvertible) 16 | } 17 | 18 | @Test("test Data is equal to data.toData()") 19 | func testDataIsEqualToDataToData() { 20 | let data = Data() 21 | #expect(data == data.toData()) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/StatusCodeTypes/HTTPRedirectionStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPRedirectionStatus: Error { 4 | static func description(from statusCode: Int) -> String { 5 | return HTTPRedirectionStatus.descriptions[statusCode] ?? "Unknown Redirection Status" 6 | } 7 | 8 | private static let descriptions: [Int: String] = [ 9 | 300: "Multiple Choices", 10 | 301: "Moved Permanently", 11 | 302: "Found", 12 | 303: "See Other", 13 | 304: "Not Modified", 14 | 305: "Use Proxy", 15 | 307: "Temporary Redirect", 16 | 308: "Permanent Redirect" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Methods/HTTPMethod.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Standard HTTP Methods 4 | public enum HTTPMethod: String, CaseIterable { 5 | /// Retrieve data from server 6 | case GET 7 | /// Submit data to server 8 | case POST 9 | /// Update existing resource 10 | case PUT 11 | /// Delete a resource 12 | case DELETE 13 | /// Update partial resource 14 | case PATCH 15 | /// Get headers only (without body) 16 | case HEAD 17 | /// Get available methods for a resource 18 | case OPTIONS 19 | /// Trace request path 20 | case TRACE 21 | /// Connect to server (for proxies) 22 | case CONNECT 23 | } 24 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/DataUploader/Protocols/DataUploadable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public protocol DataUploadable { 5 | func uploadData(_ data: Data, with request: Request, progress: UploadProgressHandler?) async throws -> Data 6 | func uploadDataTask(_ data: Data, with request: Request, progress: UploadProgressHandler?, completion: @escaping(UploadCompletionHandler)) -> URLSessionUploadTask? 7 | func uploadDataPublisher(_ data: Data, with request: Request, progress: UploadProgressHandler?) -> AnyPublisher 8 | func uploadDataStream(_ data: Data, with request: Request) -> AsyncStream 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/TaskLifecycleInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting task lifecycle events. 4 | public protocol TaskLifecycleInterceptor: AnyObject { 5 | /// Intercepts when a task completes with or without an error. 6 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) 7 | 8 | /// Intercepts when a task is waiting for connectivity. 9 | func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) 10 | 11 | /// Intercepts when a task is created. 12 | func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) 13 | } 14 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/FileUploader/Protocols/FileUploadable.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public protocol FileUploadable { 5 | func uploadFile(_ fileURL: URL, with request: Request, progress: UploadProgressHandler?) async throws -> Data 6 | func uploadFileTask(_ fileURL: URL, with request: Request, progress: UploadProgressHandler?, completion: @escaping(UploadCompletionHandler)) -> URLSessionUploadTask? 7 | func uploadFilePublisher(_ fileURL: URL, with request: Request, progress: UploadProgressHandler?) -> AnyPublisher 8 | func uploadFileStream(_ fileURL: URL, with request: Request) -> AsyncStream 9 | } 10 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionDelegate { 4 | public func urlSession(_ session: URLSession, 5 | didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 6 | await authenticationInterceptor?.urlSession(session, didReceive: challenge) ?? (.performDefaultHandling, nil) 7 | } 8 | 9 | public func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { 10 | taskLifecycleInterceptor?.urlSession(session, didCreateTask: task) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/StatusCodeTypes/HTTPSuccessStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPSuccessStatus: Error { 4 | static func description(from statusCode: Int) -> String { 5 | return HTTPSuccessStatus.descriptions[statusCode] ?? "Unknown Success Status" 6 | } 7 | 8 | private static let descriptions: [Int: String] = [ 9 | 200: "OK", 10 | 201: "Created", 11 | 202: "Accepted", 12 | 203: "Non-Authoritative Information", 13 | 204: "No Content", 14 | 205: "Reset Content", 15 | 206: "Partial Content", 16 | 207: "Multi-Status", 17 | 208: "Already Reported", 18 | 226: "IM Used" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/Helpers/DefaultUploadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal class DefaultUploadTaskInterceptor: UploadTaskInterceptor { 4 | var progress: (Double) -> Void 5 | 6 | init(progress: @escaping (Double) -> Void = { _ in }) { 7 | self.progress = progress 8 | } 9 | 10 | func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { 11 | guard totalBytesExpectedToSend > 0 else { 12 | return 13 | } 14 | let currentProgress = Double(totalBytesSent) / Double(totalBytesExpectedToSend) 15 | progress(currentProgress) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/AuthenticationInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting and handling URL authentication challenges. 4 | public protocol AuthenticationInterceptor: AnyObject { 5 | /// Intercepts authentication challenges for a specific URLSession task. 6 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) 7 | 8 | /// Intercepts authentication challenges for the entire URLSession. 9 | func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) 10 | } 11 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/albertodominguez.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | EZNetworking.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | EZNetworking 16 | 17 | primary 18 | 19 | 20 | EZNetworkingTests 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/StatusCodeTypes/HTTPInformationalStatusTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPInformationalStatus") 5 | final class HTTPInformationalStatusTests { 6 | 7 | @Test("test HTTPInformationalStatus maps status code to description", arguments: zip(map.keys, map.values)) 8 | func testHTTPInformationalStatusMapsStatusCodeToDescription(statusCode: Int, description: String) { 9 | #expect(HTTPInformationalStatus.description(from: statusCode) == description) 10 | } 11 | 12 | private static let map: [Int: String] = [ 13 | 100: "Continue", 14 | 101: "Switching Protocols", 15 | 102: "Processing", 16 | -1: "Unknown Informational Status" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/StatusCodeTypes/HTTPServerErrorStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPServerErrorStatus: Error { 4 | static func description(from statusCode: Int) -> String { 5 | return HTTPServerErrorStatus.descriptions[statusCode] ?? "Unknown Server Error" 6 | } 7 | 8 | private static let descriptions: [Int: String] = [ 9 | 500: "Internal Server Error", 10 | 501: "Not Implemented", 11 | 502: "Bad Gateway", 12 | 503: "Service Unavailable", 13 | 504: "Gateway Timeout", 14 | 505: "HTTP Version Not Supported", 15 | 506: "Variant Also Negotiates", 16 | 507: "Insufficient Storage", 17 | 508: "Loop Detected", 18 | 510: "Not Extended", 19 | 511: "Network Authentication Required" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Other/WebSocketTaskProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // protocol to allow unit testing and mocking for URLSessionWebSocketTask 4 | public protocol WebSocketTaskProtocol { 5 | func resume() 6 | func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) 7 | func sendPing(pongReceiveHandler: @escaping @Sendable ((any Error)?) -> Void) 8 | 9 | func send(_ message: URLSessionWebSocketTask.Message) async throws 10 | func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping @Sendable (Error?) -> Void) 11 | 12 | func receive() async throws -> URLSessionWebSocketTask.Message 13 | func receive(completionHandler: @escaping @Sendable (Result) -> Void) 14 | } 15 | 16 | extension URLSessionWebSocketTask: WebSocketTaskProtocol {} 17 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Encoders/HTTPParameterEncoder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol HTTPParameterEncoder { 4 | func encodeParameters(for urlRequest: inout URLRequest, with parameters: [HTTPParameter]) throws 5 | } 6 | 7 | public struct HTTPParameterEncoderImpl: HTTPParameterEncoder { 8 | public init() {} 9 | public func encodeParameters(for urlRequest: inout URLRequest, with parameters: [HTTPParameter]) throws { 10 | guard let url = urlRequest.url else { 11 | throw NetworkingError.internalError(.noURL) 12 | } 13 | 14 | if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false), !parameters.isEmpty { 15 | urlComponents.queryItems = parameters.map { URLQueryItem(name: $0.key, value: $0.value) } 16 | urlRequest.url = urlComponents.url 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/NetworkingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum NetworkingError: Error { 4 | case internalError(InternalError) /// any internal error 5 | case httpError(HTTPError) /// any HRRP error 6 | case urlError(URLError) /// any URL error 7 | } 8 | 9 | extension NetworkingError: Equatable { 10 | public static func ==(lhs: NetworkingError, rhs: NetworkingError) -> Bool { 11 | switch (lhs, rhs) { 12 | case let (.internalError(error1), .internalError(error2)): 13 | return error1 == error2 14 | 15 | case let (.httpError(error1), .httpError(error2)): 16 | return error1.statusCode == error2.statusCode 17 | 18 | case let (.urlError(error), .urlError(error2)): 19 | return error == error2 20 | 21 | default: 22 | return false 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionWebSocketDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionWebSocketDelegate { 4 | public func urlSession(_ session: URLSession, 5 | webSocketTask: URLSessionWebSocketTask, 6 | didOpenWithProtocol protocol: String?) { 7 | webSocketTaskInterceptor?.urlSession(session, webSocketTask: webSocketTask, didOpenWithProtocol: `protocol`) 8 | } 9 | 10 | public func urlSession(_ session: URLSession, 11 | webSocketTask: URLSessionWebSocketTask, 12 | didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, 13 | reason: Data?) { 14 | webSocketTaskInterceptor?.urlSession(session, webSocketTask: webSocketTask, didCloseWith: closeCode, reason: reason) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/StreamTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting stream tasks specifically. 4 | public protocol StreamTaskInterceptor: AnyObject { 5 | /// Intercepts when a stream task's read is closed. 6 | func urlSession(_ session: URLSession, readClosedFor streamTask: URLSessionStreamTask) 7 | 8 | /// Intercepts when a stream task's write is closed. 9 | func urlSession(_ session: URLSession, writeClosedFor streamTask: URLSessionStreamTask) 10 | 11 | /// Intercepts when a better route is discovered for a stream task. 12 | func urlSession(_ session: URLSession, betterRouteDiscoveredFor streamTask: URLSessionStreamTask) 13 | 14 | /// Intercepts when a stream task becomes input and output streams. 15 | func urlSession(_ session: URLSession, streamTask: URLSessionStreamTask, didBecome inputStream: InputStream, outputStream: OutputStream) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Downloader/Protocols/FileDownloadable.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public typealias DownloadProgressHandler = (Double) -> Void 5 | public typealias DownloadCompletionHandler = (Result) -> Void 6 | 7 | public protocol FileDownloadable { 8 | func downloadFile(from serverUrl: URL, progress: DownloadProgressHandler?) async throws -> URL 9 | func downloadFileTask(from serverUrl: URL, progress: DownloadProgressHandler?, completion: @escaping(DownloadCompletionHandler)) -> URLSessionDownloadTask 10 | func downloadFilePublisher(from serverUrl: URL, progress: DownloadProgressHandler?) -> AnyPublisher 11 | func downloadFileStream(from serverUrl: URL) -> AsyncStream 12 | } 13 | 14 | public enum DownloadStreamEvent { 15 | case progress(Double) 16 | case success(URL) 17 | case failure(NetworkingError) 18 | } 19 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/StatusCodeTypes/HTTPRedirectionStatusTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPRedirectionStatus") 5 | final class HTTPRedirectionStatusTests { 6 | 7 | @Test("test HTTPRedirectionStatus maps status code to description", arguments: zip(map.keys, map.values)) 8 | func testHTTPRedirectionStatusMapsStatusCodeToDescription(statusCode: Int, description: String) { 9 | #expect(HTTPRedirectionStatus.description(from: statusCode) == description) 10 | } 11 | 12 | private static let map: [Int: String] = [ 13 | 300: "Multiple Choices", 14 | 301: "Moved Permanently", 15 | 302: "Found", 16 | 303: "See Other", 17 | 304: "Not Modified", 18 | 305: "Use Proxy", 19 | 307: "Temporary Redirect", 20 | 308: "Permanent Redirect", 21 | -1: "Unknown Redirection Status" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/StatusCodeTypes/HTTPSuccessStatusTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPSuccessStatus") 5 | final class HTTPSuccessStatusTests { 6 | 7 | @Test("test HTTPSuccessStatus maps status code to description", arguments: zip(map.keys, map.values)) 8 | func testHTTPSuccessStatusMapsStatusCodeToDescription(statusCode: Int, description: String) { 9 | #expect(HTTPSuccessStatus.description(from: statusCode) == description) 10 | } 11 | 12 | private static let map: [Int: String] = [ 13 | 200: "OK", 14 | 201: "Created", 15 | 202: "Accepted", 16 | 203: "Non-Authoritative Information", 17 | 204: "No Content", 18 | 205: "Reset Content", 19 | 206: "Partial Content", 20 | 207: "Multi-Status", 21 | 208: "Already Reported", 22 | 226: "IM Used", 23 | -1: "Unknown Success Status" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/WebSocket/Helpers/DefaultWebSocketTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | internal class DefaultWebSocketTaskInterceptor: WebSocketTaskInterceptor { 4 | var onEvent: ((WebSocketTaskEvent) -> Void)? 5 | 6 | init(onEvent: ((WebSocketTaskEvent) -> Void)? = nil) { 7 | self.onEvent = onEvent 8 | } 9 | 10 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { 11 | onEvent?(.didOpenWithProtocol(protocolStr: `protocol`)) 12 | } 13 | 14 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: any Error) { 15 | onEvent?(.didOpenWithError(error: error)) 16 | } 17 | 18 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { 19 | onEvent?(.didClose(code: closeCode, reason: reason)) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/DownloadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting download tasks specifically. 4 | public protocol DownloadTaskInterceptor: AnyObject { 5 | /// Track the progress of the download process 6 | var progress: (Double) -> Void { get set } 7 | 8 | /// Intercepts when a download task finishes downloading to a location. 9 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) 10 | 11 | /// Intercepts progress updates during the download task. 12 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) 13 | 14 | /// Intercepts when a download task is resumed. 15 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/DataTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting data tasks specifically. 4 | public protocol DataTaskInterceptor: AnyObject { 5 | /// Intercepts data received for a specific data task. 6 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) 7 | 8 | /// Intercepts when a data task transitions to a download task. 9 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome downloadTask: URLSessionDownloadTask) 10 | 11 | /// Intercepts when a data task transitions to a stream task. 12 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome streamTask: URLSessionStreamTask) 13 | 14 | /// Intercepts the response for a data task before it is processed. 15 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition 16 | } 17 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Encoders/HTTPParameterEncoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test HTTPParameterEncoderImpl") 6 | final class HTTPParameterEncoderTests { 7 | private let sut = HTTPParameterEncoderImpl() 8 | 9 | @Test("test URL Query Parameters Are Added") 10 | func testURLQueryParametersAreAdded() throws { 11 | let url = try #require(URL(string: "https://www.example.com")) 12 | var urlRequest = URLRequest(url: url) 13 | 14 | try sut.encodeParameters(for: &urlRequest, with: [ 15 | HTTPParameter(key: "key_1", value: "value_1"), 16 | HTTPParameter(key: "key_2", value: "value_2"), 17 | HTTPParameter(key: "key_3", value: "value_3") 18 | ]) 19 | 20 | let expectedURL = "https://www.example.com?key_1=value_1&key_2=value_2&key_3=value_3" 21 | #expect(urlRequest.url?.absoluteString == expectedURL) 22 | } 23 | 24 | } 25 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Mocks/Mock-JSON.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | @testable import EZNetworking 3 | 4 | struct MockData { 5 | static var mockPersonJsonData: Data { 6 | let jsonString = """ 7 | { 8 | "name": "John", 9 | "age": 30 10 | } 11 | """ 12 | return Data(jsonString: jsonString)! 13 | } 14 | 15 | static var invalidMockPersonJsonData: Data { 16 | let jsonString = """ 17 | { 18 | "Name": "John", 19 | "Age": 30 20 | } 21 | """ 22 | return Data(jsonString: jsonString)! 23 | } 24 | 25 | static func imageUrlData(from imageUrlString: String) -> Data? { 26 | guard let url = URL(string: imageUrlString) else { 27 | return nil 28 | } 29 | do { 30 | let data = try Data(contentsOf: url) 31 | return data 32 | } catch { 33 | return nil 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "EZNetworking", 8 | platforms: [ 9 | .iOS(.v15) // Set the minimum iOS version here 10 | ], 11 | products: [ 12 | // Products define the executables and libraries a package produces, making them visible to other packages. 13 | .library( 14 | name: "EZNetworking", 15 | targets: ["EZNetworking"]), 16 | ], 17 | targets: [ 18 | // Targets are the basic building blocks of a package, defining a module or a test suite. 19 | // Targets can depend on other targets in this package and products from dependencies. 20 | .target( 21 | name: "EZNetworking"), 22 | .testTarget( 23 | name: "EZNetworkingTests", 24 | dependencies: ["EZNetworking"]), 25 | ] 26 | ) 27 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Downloader/Mocks/FileDownloader_MockDownloadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class FileDownloader_MockDownloadTaskInterceptor: DownloadTaskInterceptor { 5 | var progress: (Double) -> Void 6 | 7 | init(progress: @escaping (Double) -> Void) { 8 | self.progress = progress 9 | } 10 | 11 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 12 | progress(1) 13 | } 14 | 15 | var didCallDidWriteData = false 16 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 17 | didCallDidWriteData = true 18 | progress(1) 19 | } 20 | 21 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { 22 | progress(1) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Encoders/HTTPHeaderEncoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test HTTPHeaderEncoderImpl") 6 | final class HTTPHeaderEncoderTests { 7 | private let sut = HTTPHeaderEncoderImpl() 8 | 9 | @Test("test allHTTPHeaderFields is set to injected headers") 10 | func testAllHTTPHeaderFieldsIsSetToInjectedHeaders() throws { 11 | let url = try #require(URL(string: "https://www.example.com")) 12 | var urlRequest = URLRequest(url: url) 13 | 14 | sut.encodeHeaders(for: &urlRequest, with: [ 15 | .accept(.json), 16 | .contentType(.json), 17 | .authorization(.bearer("My_API_KEY")) 18 | ]) 19 | 20 | let expextedHeaders = [ 21 | "Accept": "application/json", 22 | "Content-Type": "application/json", 23 | "Authorization": "Bearer My_API_KEY" 24 | ] 25 | #expect(urlRequest.allHTTPHeaderFields == expextedHeaders) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/StatusCodeTypes/HTTPServerErrorStatusTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPServerErrorStatus") 5 | final class HTTPServerErrorStatusTests { 6 | 7 | @Test("test HTTPServerErrorStatus maps status code to description", arguments: zip(map.keys, map.values)) 8 | func testHTTPServerErrorStatusMapsStatusCodeToDescription(statusCode: Int, description: String) { 9 | #expect(HTTPServerErrorStatus.description(from: statusCode) == description) 10 | } 11 | 12 | private static let map: [Int: String] = [ 13 | 500: "Internal Server Error", 14 | 501: "Not Implemented", 15 | 502: "Bad Gateway", 16 | 503: "Service Unavailable", 17 | 504: "Gateway Timeout", 18 | 505: "HTTP Version Not Supported", 19 | 506: "Variant Also Negotiates", 20 | 507: "Insufficient Storage", 21 | 508: "Loop Detected", 22 | 510: "Not Extended", 23 | 511: "Network Authentication Required", 24 | -1: "Unknown Server Error" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Decoding/RequestDecoderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test RequestDecoder") 5 | final class RequestDecoderTests { 6 | private let sut = RequestDecoder() 7 | 8 | @Test("can decode valid mock JSON into Decodable object") 9 | func canDecodeValidMockJSONIntoDecodableObject() throws { 10 | do { 11 | let person = try sut.decode(Person.self, from: MockData.mockPersonJsonData) 12 | #expect(person.name == "John") 13 | #expect(person.age == 30) 14 | } catch { 15 | Issue.record("Unexpected error)") 16 | } 17 | } 18 | 19 | @Test("thorws error if tries to decode invalid mock json") 20 | func throwsErrorIfTriesToDecodeInvalidMockJSON() throws { 21 | do { 22 | _ = try sut.decode(Person.self, from: MockData.invalidMockPersonJsonData) 23 | Issue.record("Unexpected error)") 24 | } catch let error as NetworkingError { 25 | #expect(error == NetworkingError.internalError(.couldNotParse)) 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/Protocols/WebSocketTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Protocol for intercepting WebSocket tasks specifically. 4 | public protocol WebSocketTaskInterceptor: AnyObject { 5 | var onEvent: ((WebSocketTaskEvent) -> Void)? { get set } 6 | 7 | /// Intercepts when a WebSocket task opens with a protocol. 8 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) 9 | 10 | /// Intercepts when a WebSocket task closes with a code and reason. 11 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) 12 | 13 | /// Intercepts when a task completes with an error. 14 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error) 15 | } 16 | 17 | public enum WebSocketTaskEvent { 18 | case didOpenWithProtocol(protocolStr: String?) 19 | case didOpenWithError(error: Error) 20 | case didClose(code: URLSessionWebSocketTask.CloseCode, reason: Data?) 21 | } 22 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/WebSocket/Helpers/WebSocketConnectionState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum WebSocketConnectionState: Equatable { 4 | case idle 5 | case disconnected 6 | case connecting 7 | case connected(protocol: String?) 8 | case connectionLost(reason: WebSocketError) 9 | case failed(error: WebSocketError) 10 | 11 | public static func == (lhs: WebSocketConnectionState, rhs: WebSocketConnectionState) -> Bool { 12 | switch (lhs, rhs) { 13 | case (.idle, .idle): 14 | return true 15 | case (.disconnected, .disconnected): 16 | return true 17 | case (.connecting, .connecting): 18 | return true 19 | case (.connected(let lhsProto), .connected(let rhsProto)): 20 | return lhsProto == rhsProto 21 | case (.connectionLost(let lhsError), .connectionLost(let rhsError)): 22 | return lhsError == rhsError 23 | case (.failed(let lhsError), .failed(let rhsError)): 24 | return lhsError == rhsError 25 | default: 26 | return false 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alberto Dominguez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Mocks/MockURLResponseValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | struct MockURLResponseValidator: ResponseValidator { 5 | var throwError: NetworkingError? = nil 6 | 7 | func validateNoError(_ error: (any Error)?) throws { 8 | if let throwError = throwError { 9 | throw throwError 10 | } 11 | } 12 | 13 | func validateStatus(from urlResponse: URLResponse?) throws { 14 | if let throwError = throwError { 15 | throw throwError 16 | } 17 | } 18 | 19 | func validateData(_ data: Data?) throws -> Data { 20 | if let throwError = throwError { 21 | throw throwError 22 | } 23 | guard let data else { 24 | throw NetworkingError.internalError(.noData) 25 | } 26 | return data 27 | } 28 | 29 | func validateUrl(_ url: URL?) throws -> URL { 30 | if let throwError = throwError { 31 | throw throwError 32 | } 33 | guard let url else { 34 | throw NetworkingError.internalError(.noURL) 35 | } 36 | return url 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Other/URLSessionTaskProtocol.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol URLSessionTaskProtocol { 4 | 5 | func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask 6 | 7 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask 8 | 9 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask 10 | 11 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask 12 | 13 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol 14 | } 15 | 16 | extension URLSession: URLSessionTaskProtocol { 17 | public func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 18 | let task: URLSessionWebSocketTask = self.webSocketTask(with: request) 19 | return task as WebSocketTaskProtocol 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Downloader/Helpers/DefaultDownloadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | /// Default implementation of DownloadTaskInterceptor 5 | internal class DefaultDownloadTaskInterceptor: DownloadTaskInterceptor { 6 | var progress: (Double) -> Void 7 | 8 | init(progress: @escaping (Double) -> Void = { _ in }) { 9 | self.progress = progress 10 | } 11 | 12 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 13 | progress(1.0) 14 | } 15 | 16 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 17 | let currentProgress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) 18 | progress(currentProgress) 19 | } 20 | 21 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { 22 | let currentProgress = Double(fileOffset) / Double(expectedTotalBytes) 23 | progress(currentProgress) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/InternalError/InternalErrorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test InternalError") 5 | final class InternalErrorTests { 6 | 7 | @Test("test InternalError Is Equatable", arguments: zip(InternalErrorList, InternalErrorList)) 8 | func testCouldNotParseIsEquatable(inputA: InternalError, inputB: InternalError) { 9 | #expect(inputA == inputB) 10 | } 11 | 12 | @Test("test Different InternalError Are Not Equatable") 13 | func testDifferentInternalErrorAreNotEquatable() { 14 | #expect(InternalError.unknown != InternalError.couldNotParse) 15 | } 16 | 17 | private static let InternalErrorList: [InternalError] = [ 18 | InternalError.noURL, 19 | InternalError.couldNotParse, 20 | InternalError.invalidError, 21 | InternalError.noData, 22 | InternalError.noResponse, 23 | InternalError.requestFailed(NetworkingError.httpError(.init(statusCode: 400, headers: [:]))), 24 | InternalError.noRequest, 25 | InternalError.noHTTPURLResponse, 26 | InternalError.invalidImageData, 27 | InternalError.lostReferenceOfSelf, 28 | InternalError.unknown, 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionStreamDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionStreamDelegate { 4 | public func urlSession(_ session: URLSession, 5 | readClosedFor streamTask: URLSessionStreamTask) { 6 | streamTaskInterceptor?.urlSession(session, readClosedFor: streamTask) 7 | } 8 | 9 | public func urlSession(_ session: URLSession, 10 | writeClosedFor streamTask: URLSessionStreamTask) { 11 | streamTaskInterceptor?.urlSession(session, writeClosedFor: streamTask) 12 | } 13 | 14 | public func urlSession(_ session: URLSession, 15 | betterRouteDiscoveredFor streamTask: URLSessionStreamTask) { 16 | streamTaskInterceptor?.urlSession(session, betterRouteDiscoveredFor: streamTask) 17 | } 18 | 19 | public func urlSession(_ session: URLSession, 20 | streamTask: URLSessionStreamTask, 21 | didBecome inputStream: InputStream, 22 | outputStream: OutputStream) { 23 | streamTaskInterceptor?.urlSession(session, streamTask: streamTask, didBecome: inputStream, outputStream: outputStream) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/InternalError/InternalError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum InternalError: Error { 4 | case noURL 5 | case couldNotParse 6 | case invalidError 7 | case noData 8 | case noResponse 9 | case requestFailed(Error) 10 | case noRequest 11 | case noHTTPURLResponse 12 | case invalidImageData 13 | case lostReferenceOfSelf 14 | case unknown 15 | } 16 | 17 | extension InternalError: Equatable { 18 | public static func ==(lhs: InternalError, rhs: InternalError) -> Bool { 19 | switch (lhs, rhs) { 20 | case (.unknown, .unknown), 21 | (.noURL, .noURL), 22 | (.couldNotParse, .couldNotParse), 23 | (.invalidError, .invalidError), 24 | (.noData, .noData), 25 | (.noResponse, .noResponse), 26 | (.noRequest, .noRequest), 27 | (.noHTTPURLResponse, .noHTTPURLResponse), 28 | (.invalidImageData, .invalidImageData), 29 | (.lostReferenceOfSelf, .lostReferenceOfSelf): 30 | return true 31 | 32 | case let (.requestFailed(lhsError), .requestFailed(rhsError)): 33 | return (lhsError as NSError) == (rhsError as NSError) 34 | 35 | default: 36 | return false 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionDownloadDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionDownloadDelegate { 4 | public func urlSession(_ session: URLSession, 5 | downloadTask: URLSessionDownloadTask, 6 | didFinishDownloadingTo location: URL) { 7 | downloadTaskInterceptor?.urlSession(session, downloadTask: downloadTask, didFinishDownloadingTo: location) 8 | } 9 | 10 | public func urlSession(_ session: URLSession, 11 | downloadTask: URLSessionDownloadTask, 12 | didWriteData bytesWritten: Int64, 13 | totalBytesWritten: Int64, 14 | totalBytesExpectedToWrite: Int64) { 15 | downloadTaskInterceptor?.urlSession(session, downloadTask: downloadTask, didWriteData: bytesWritten, totalBytesWritten: totalBytesWritten, totalBytesExpectedToWrite: totalBytesExpectedToWrite) 16 | } 17 | 18 | public func urlSession(_ session: URLSession, 19 | downloadTask: URLSessionDownloadTask, 20 | didResumeAtOffset fileOffset: Int64, 21 | expectedTotalBytes: Int64) { 22 | downloadTaskInterceptor?.urlSession(session, downloadTask: downloadTask, didResumeAtOffset: fileOffset, expectedTotalBytes: expectedTotalBytes) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/StatusCodeTypes/HTTPClientErrorStatus.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | enum HTTPClientErrorStatus: Error { 4 | static func description(from statusCode: Int) -> String { 5 | HTTPClientErrorStatus.descriptions[statusCode] ?? "Unknown Client Error" 6 | } 7 | 8 | private static let descriptions: [Int: String] = [ 9 | 400: "Bad Request", 10 | 401: "Unauthorized", 11 | 402: "Payment Required", 12 | 403: "Forbidden", 13 | 404: "Not Found", 14 | 405: "Method Not Allowed", 15 | 406: "Not Acceptable", 16 | 407: "Proxy Authentication Required", 17 | 408: "Request Timeout", 18 | 409: "Conflict", 19 | 410: "Gone", 20 | 411: "Length Required", 21 | 412: "Precondition Failed", 22 | 413: "Payload Too Large", 23 | 414: "URI Too Long", 24 | 415: "Unsupported Media Type", 25 | 416: "Range Not Satisfiable", 26 | 417: "Expectation Failed", 27 | 418: "I'm a teapot", 28 | 421: "Misdirected Request", 29 | 422: "Unprocessable Entity", 30 | 423: "Locked", 31 | 424: "Failed Dependency", 32 | 425: "Too Early", 33 | 426: "Upgrade Required", 34 | 428: "Precondition Required", 35 | 429: "Too Many Requests", 36 | 431: "Request Header Fields Too Large", 37 | 451: "Unavailable For Legal Reasons" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Request/RequestFactory.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RequestFactory { 4 | func build(httpMethod: HTTPMethod, 5 | baseUrlString: String, 6 | parameters: [HTTPParameter]?, 7 | headers: [HTTPHeader]?, 8 | body: HTTPBody?, 9 | timeoutInterval: TimeInterval, 10 | cachePolicy: URLRequest.CachePolicy) -> Request 11 | } 12 | 13 | public class RequestFactoryImpl: RequestFactory { 14 | private let headerEncoder: HTTPHeaderEncoder 15 | private let paramEncoder: HTTPParameterEncoder 16 | 17 | public init(headerEncoder: HTTPHeaderEncoder = HTTPHeaderEncoderImpl(), 18 | paramEncoder: HTTPParameterEncoder = HTTPParameterEncoderImpl()) { 19 | self.headerEncoder = headerEncoder 20 | self.paramEncoder = paramEncoder 21 | } 22 | 23 | public func build(httpMethod: HTTPMethod, 24 | baseUrlString: String, 25 | parameters: [HTTPParameter]?, 26 | headers: [HTTPHeader]? = nil, 27 | body: HTTPBody? = nil, 28 | timeoutInterval: TimeInterval = 60, 29 | cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy 30 | ) -> Request { 31 | return EZRequest(httpMethod: httpMethod, baseUrlString: baseUrlString, parameters: parameters, headers: headers, body: body, timeoutInterval: timeoutInterval, cachePolicy: cachePolicy) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/HTTPErrorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPErrorTests") 5 | final class HTTPErrorTests { 6 | 7 | private static let statusCodeToCategoryMap: [Int: HTTPError.HTTPErrorCategory] = [ 8 | 100: HTTPError.HTTPErrorCategory.informational, 9 | 200: HTTPError.HTTPErrorCategory.success, 10 | 300: HTTPError.HTTPErrorCategory.redirection, 11 | 400: HTTPError.HTTPErrorCategory.clientError, 12 | 500: HTTPError.HTTPErrorCategory.serverError 13 | ] 14 | @Test("test HTTPError category given status code", arguments: zip(statusCodeToCategoryMap.keys, statusCodeToCategoryMap.values)) 15 | func testHTTPErrorCategoryGivenStatusCode(statusCode: Int, expectedCategory: HTTPError.HTTPErrorCategory) { 16 | let sut = HTTPError(statusCode: statusCode, headers: [:]) 17 | #expect(sut.category == expectedCategory) 18 | } 19 | 20 | private static let statusCodeToDescriptionMap: [Int: String] = [ 21 | 100: "Continue", 22 | 200: "OK", 23 | 300: "Multiple Choices", 24 | 400: "Bad Request", 25 | 500: "Internal Server Error" 26 | ] 27 | @Test("test HTTPError description given status code", arguments: zip(statusCodeToDescriptionMap.keys, statusCodeToDescriptionMap.values)) 28 | func testHTTPErrorDescriptionGivenStatusCode(statusCode: Int, expectedDescription: String) { 29 | let sut = HTTPError(statusCode: statusCode, headers: [:]) 30 | #expect(sut.description == expectedDescription) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/WebSocket/Mocks/MockWebSockerURLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockWebSockerURLSession: URLSessionTaskProtocol { 5 | private let webSocketTask: MockURLSessionWebSocketTask 6 | 7 | var didCallWebSocketTaskInspectable = false 8 | 9 | init(webSocketTask: MockURLSessionWebSocketTask = MockURLSessionWebSocketTask()) { 10 | self.webSocketTask = webSocketTask 11 | } 12 | 13 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 14 | didCallWebSocketTaskInspectable = true 15 | return webSocketTask 16 | } 17 | } 18 | 19 | // MARK: unused methods 20 | 21 | extension MockWebSockerURLSession { 22 | func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 23 | fatalError("Should not be using in this mock") 24 | } 25 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask { 26 | fatalError("Should not be using in this mock") 27 | } 28 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 29 | fatalError("Should not be using in this mock") 30 | } 31 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 32 | fatalError("Should not be using in this mock") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Other/Extensions/String-extTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test String Extensions") 6 | final class StringExtensinoTests { 7 | 8 | // MARK: - .getRandomMultiPartFormBoundary() 9 | 10 | private let prefix = "EZNetworking.Boundary." 11 | 12 | @Test("generated boundary has expected prefix and UUID suffix") 13 | func test_generatedBoundary_hasPrefixAndUuidSuffix() { 14 | let boundary = String.getRandomMultiPartFormBoundary() 15 | #expect(boundary.hasPrefix(prefix)) 16 | 17 | let uuidPart = String(boundary.dropFirst(prefix.count)) 18 | #expect(UUID(uuidString: uuidPart) != nil) 19 | } 20 | 21 | @Test("consecutive calls produce different values") 22 | func test_consecutiveCalls_areDifferent() { 23 | let first = String.getRandomMultiPartFormBoundary() 24 | let second = String.getRandomMultiPartFormBoundary() 25 | #expect(first != second) 26 | } 27 | 28 | @Test("multiple calls produce unique values") 29 | func test_multipleCalls_areUnique() { 30 | let iterations = 200 31 | var seen = Set() 32 | for _ in 0.. prefix.count) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Request/Request.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Request { 4 | var httpMethod: HTTPMethod { get } 5 | var baseUrlString: String { get } 6 | var parameters: [HTTPParameter]? { get } 7 | var headers: [HTTPHeader]? { get } 8 | var body: HTTPBody? { get } 9 | var timeoutInterval: TimeInterval { get } 10 | var cachePolicy: URLRequest.CachePolicy { get } 11 | } 12 | 13 | // default values 14 | public extension Request { 15 | var timeoutInterval: TimeInterval { 60 } 16 | var cachePolicy: URLRequest.CachePolicy { .useProtocolCachePolicy } 17 | } 18 | 19 | // additions 20 | public extension Request { 21 | var urlRequest: URLRequest? { 22 | guard let url = URL(string: baseUrlString) else { 23 | return nil 24 | } 25 | 26 | var request = URLRequest(url: url) 27 | request.httpMethod = httpMethod.rawValue 28 | request.httpBody = body?.toData() 29 | request.timeoutInterval = timeoutInterval 30 | request.cachePolicy = cachePolicy 31 | 32 | if let parameters = parameters { 33 | try? HTTPParameterEncoderImpl().encodeParameters(for: &request, with: parameters) 34 | } 35 | 36 | if let headers = headers { 37 | HTTPHeaderEncoderImpl().encodeHeaders(for: &request, with: headers) 38 | } 39 | 40 | return request 41 | } 42 | } 43 | 44 | internal struct EZRequest: Request { 45 | var httpMethod: HTTPMethod 46 | var baseUrlString: String 47 | var parameters: [HTTPParameter]? 48 | var headers: [HTTPHeader]? 49 | var body: HTTPBody? 50 | var timeoutInterval: TimeInterval 51 | var cachePolicy: URLRequest.CachePolicy 52 | } 53 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/HTTPError/StatusCodeTypes/HTTPClientErrorStatusTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPClientErrorStatus") 5 | final class HTTPClientErrorStatusTests { 6 | 7 | @Test("test HTTPClientErrorStatus maps status code to description", arguments: zip(map.keys, map.values)) 8 | func testHTTPClientErrorStatusMapsStatusCodeToDescription(statusCode: Int, description: String) { 9 | #expect(HTTPClientErrorStatus.description(from: statusCode) == description) 10 | } 11 | 12 | private static let map: [Int: String] = [ 13 | 400: "Bad Request", 14 | 401: "Unauthorized", 15 | 402: "Payment Required", 16 | 403: "Forbidden", 17 | 404: "Not Found", 18 | 405: "Method Not Allowed", 19 | 406: "Not Acceptable", 20 | 407: "Proxy Authentication Required", 21 | 408: "Request Timeout", 22 | 409: "Conflict", 23 | 410: "Gone", 24 | 411: "Length Required", 25 | 412: "Precondition Failed", 26 | 413: "Payload Too Large", 27 | 414: "URI Too Long", 28 | 415: "Unsupported Media Type", 29 | 416: "Range Not Satisfiable", 30 | 417: "Expectation Failed", 31 | 418: "I'm a teapot", 32 | 421: "Misdirected Request", 33 | 422: "Unprocessable Entity", 34 | 423: "Locked", 35 | 424: "Failed Dependency", 36 | 425: "Too Early", 37 | 426: "Upgrade Required", 38 | 428: "Precondition Required", 39 | 429: "Too Many Requests", 40 | 431: "Request Header Fields Too Large", 41 | 451: "Unavailable For Legal Reasons", 42 | -1: "Unknown Client Error" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/WebSocket/Mocks/MockWebSocketTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | let webSocketUrl = URL(string: "ws://127.0.0.1:8080/example")! 5 | var webSocketRequest: URLRequest { URLRequest(url: webSocketUrl) } 6 | 7 | class MockWebSocketTaskInterceptor: WebSocketTaskInterceptor { 8 | private let session = URLSession.shared 9 | private lazy var task: URLSessionWebSocketTask = { 10 | session.webSocketTask(with: webSocketUrl, protocols: []) 11 | }() 12 | 13 | var onEvent: ((WebSocketTaskEvent) -> Void)? 14 | 15 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { 16 | onEvent?(.didOpenWithProtocol(protocolStr: `protocol`)) 17 | } 18 | 19 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: any Error) { 20 | onEvent?(.didOpenWithError(error: error)) 21 | } 22 | 23 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { 24 | onEvent?(.didClose(code: closeCode, reason: reason)) 25 | } 26 | 27 | // simulate methods 28 | 29 | func simulateOpenWithProtocol(_ proto: String?) { 30 | urlSession(session, webSocketTask: task, didOpenWithProtocol: proto) 31 | } 32 | 33 | func simulateDidCompleteWithError(error: any Error) { 34 | urlSession(session, task: task, didCompleteWithError: error) 35 | } 36 | 37 | func simulateDidCloseWithCloseCode(didCloseWith: URLSessionWebSocketTask.CloseCode, reason: Data?) { 38 | urlSession(session, webSocketTask: task, didCloseWith: didCloseWith, reason: reason) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionDataDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionDataDelegate { 4 | public func urlSession(_ session: URLSession, 5 | dataTask: URLSessionDataTask, 6 | willCacheResponse proposedResponse: CachedURLResponse) async -> CachedURLResponse? { 7 | await cacheInterceptor?.urlSession(session, dataTask: dataTask, willCacheResponse: proposedResponse) ?? proposedResponse 8 | } 9 | 10 | public func urlSession(_ session: URLSession, 11 | dataTask: URLSessionDataTask, 12 | didReceive response: URLResponse) async -> URLSession.ResponseDisposition { 13 | await dataTaskInterceptor?.urlSession(session, dataTask: dataTask, didReceive: response) ?? .allow 14 | } 15 | 16 | public func urlSession(_ session: URLSession, 17 | dataTask: URLSessionDataTask, 18 | didReceive data: Data) { 19 | dataTaskInterceptor?.urlSession(session, dataTask: dataTask, didReceive: data) 20 | } 21 | 22 | public func urlSession(_ session: URLSession, 23 | dataTask: URLSessionDataTask, 24 | didBecome downloadTask: URLSessionDownloadTask) { 25 | dataTaskInterceptor?.urlSession(session, dataTask: dataTask, didBecome: downloadTask) 26 | } 27 | 28 | public func urlSession(_ session: URLSession, 29 | dataTask: URLSessionDataTask, 30 | didBecome streamTask: URLSessionStreamTask) { 31 | dataTaskInterceptor?.urlSession(session, dataTask: dataTask, didBecome: streamTask) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Uploader/Helpers/DefaultUploadTaskInterceptor.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test DefaultUploadTaskInterceptor") 6 | final class DefaultUploadTaskInterceptorTests { 7 | 8 | @Test("test DefaultUploadTaskInterceptor can track 0% progress") 9 | func test_0_percent_progress_tracking() { 10 | var trackedProgress: Double = 0 11 | let sut = DefaultUploadTaskInterceptor { progress in 12 | trackedProgress = progress 13 | } 14 | sut.urlSession(.shared, task: mockDataTask, didSendBodyData: 0, totalBytesSent: 0, totalBytesExpectedToSend: 100) 15 | #expect(trackedProgress == 0) 16 | } 17 | 18 | @Test("test DefaultUploadTaskInterceptor can track 50% progress") 19 | func test_50_percent_progress_tracking() { 20 | var trackedProgress: Double = 0 21 | let sut = DefaultUploadTaskInterceptor { progress in 22 | trackedProgress = progress 23 | } 24 | sut.urlSession(.shared, task: mockDataTask, didSendBodyData: 0, totalBytesSent: 50, totalBytesExpectedToSend: 100) 25 | #expect(trackedProgress == 0.5) 26 | } 27 | 28 | @Test("test DefaultUploadTaskInterceptor can track 100% progress") 29 | func test_100_percent_progress_tracking() { 30 | var trackedProgress: Double = 0 31 | let sut = DefaultUploadTaskInterceptor { progress in 32 | trackedProgress = progress 33 | } 34 | sut.urlSession(.shared, task: mockDataTask, didSendBodyData: 0, totalBytesSent: 100, totalBytesExpectedToSend: 100) 35 | #expect(trackedProgress == 1) 36 | } 37 | 38 | 39 | private let mockUrl: URL = URL(string: "https://example.com")! 40 | private var mockDataTask: URLSessionDataTask { URLSessionDataTask() } 41 | } 42 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Performers/Mocks/MockRequestPerformerURLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockRequestPerformerURLSession: URLSessionTaskProtocol { 5 | var data: Data? 6 | var urlResponse: URLResponse? 7 | var error: Error? 8 | var completion: ((Data?, URLResponse?, Error?) -> Void)? 9 | var sessionDelegate: SessionDelegate? = nil 10 | 11 | init(data: Data? = nil, urlResponse: URLResponse? = nil, error: Error? = nil) { 12 | self.data = data 13 | self.urlResponse = urlResponse 14 | self.error = error 15 | } 16 | 17 | func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 18 | self.completion = completionHandler 19 | return MockURLSessionDataTask { 20 | completionHandler(self.data, self.urlResponse, self.error) 21 | } 22 | } 23 | } 24 | 25 | // MARK: unused methods 26 | 27 | extension MockRequestPerformerURLSession { 28 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask { 29 | fatalError("Should not be using in this mock") 30 | } 31 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 32 | fatalError("Should not be using in this mock") 33 | } 34 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 35 | fatalError("Should not be using in this mock") 36 | } 37 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 38 | fatalError("Should not be using in this mock") 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/HTTPError/HTTPError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct HTTPError: Error { 4 | public let statusCode: Int 5 | public let headers: [AnyHashable: Any] 6 | public let category: HTTPErrorCategory 7 | 8 | public init(statusCode: Int, headers: [AnyHashable: Any] = [:]) { 9 | self.statusCode = statusCode 10 | self.headers = headers 11 | self.category = HTTPErrorCategory.from(statusCode: statusCode) 12 | } 13 | 14 | public var description: String { 15 | switch category { 16 | case .informational: 17 | return HTTPInformationalStatus.description(from: statusCode) 18 | case .success: 19 | return HTTPSuccessStatus.description(from: statusCode) 20 | case .redirection: 21 | return HTTPRedirectionStatus.description(from: statusCode) 22 | case .clientError: 23 | return HTTPClientErrorStatus.description(from: statusCode) 24 | case .serverError: 25 | return HTTPServerErrorStatus.description(from: statusCode) 26 | case .unknown: 27 | return "Unknown Status Code (\(statusCode))" 28 | } 29 | } 30 | 31 | public enum HTTPErrorCategory { 32 | case informational // 1xx 33 | case success // 2xx 34 | case redirection // 3xx 35 | case clientError // 4xx 36 | case serverError // 5xx 37 | case unknown // Other 38 | 39 | static func from(statusCode: Int) -> HTTPErrorCategory { 40 | switch statusCode { 41 | case 100...199: return .informational 42 | case 200...299: return .success 43 | case 300...399: return .redirection 44 | case 400...499: return .clientError 45 | case 500...599: return .serverError 46 | default: return .unknown 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Downloader/Helpers/DefaultDownloadTaskInterceptorTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import EZNetworking 3 | import Foundation 4 | import Testing 5 | 6 | 7 | @Suite("Test DefaultDownloadTaskInterceptor") 8 | final class DefaultDownloadTaskInterceptorTests { 9 | 10 | @Test("test .urlSession(_, downloadTask:_, didFinishDownloadingTo:_) tracks progress 100%") 11 | func test_didFinishDownloadingTo_tracksProgress_completion() { 12 | var trackedProgress: Double = 0 13 | let sut = DefaultDownloadTaskInterceptor { progress in 14 | trackedProgress = progress 15 | } 16 | sut.urlSession(.shared, downloadTask: mockDownloadTask, didFinishDownloadingTo: mockUrl) 17 | #expect(trackedProgress == 1.0) 18 | } 19 | 20 | @Test("test .urlSession(_, downloadTask:_, didResumeAtOffset:_, expectedTotalBytes:_) tracks partial progress") 21 | func test_didResumeAtOffset_tracksProgress() { 22 | var trackedProgress: Double = 0 23 | let sut = DefaultDownloadTaskInterceptor { progress in 24 | trackedProgress = progress 25 | } 26 | sut.urlSession(.shared, downloadTask: mockDownloadTask, didResumeAtOffset: 50, expectedTotalBytes: 100) 27 | #expect(trackedProgress == 0.5) 28 | } 29 | 30 | @Test("test .urlSession(_, downloadTask:_, didWriteData:_, totalBytesWritten:_, totalBytesExpectedToWrite:_) tracks partial progress") 31 | func test_didWriteData_tracksProgress_() { 32 | var trackedProgress: Double = 0 33 | let sut = DefaultDownloadTaskInterceptor { progress in 34 | trackedProgress = progress 35 | } 36 | sut.urlSession(.shared, downloadTask: mockDownloadTask, didWriteData: 10, totalBytesWritten: 50, totalBytesExpectedToWrite: 100) 37 | #expect(trackedProgress == 0.5) 38 | } 39 | } 40 | 41 | private let mockUrl: URL = URL(string: "https://example.com")! 42 | private var mockDownloadTask: URLSessionDownloadTask { URLSessionDownloadTask() } 43 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/HTTP/Body/MultipartFormData/MultipartFormPartTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test MultipartFormPart") 6 | class MultipartFormPartTests { 7 | 8 | @Test("explicit MultipartFormPart.file sets all properties") 9 | func test_MultipartFormPartfile_setsProperties() { 10 | let payload = "hello".data(using: .utf8)! 11 | let part = MultipartFormPart.filePart(name: "field", 12 | data: payload, 13 | filename: "file.txt", 14 | mimeType: .plain) 15 | 16 | #expect(part.name == "field") 17 | #expect(part.filename == "file.txt") 18 | #expect(part.mimeType == .plain) 19 | #expect(part.data == payload) 20 | #expect(part.contentLength == 5) 21 | } 22 | 23 | @Test("explicit MultipartFormPart.string sets all properties") 24 | func test_MultipartFormPartString_setsProperties() { 25 | let payload = "value".data(using: .utf8)! 26 | let part = MultipartFormPart.fieldPart(name: "file", value: "value") 27 | 28 | #expect(part.name == "file") 29 | #expect(part.filename == nil) 30 | #expect(part.mimeType == .plain) 31 | #expect(part.data == payload) 32 | #expect(part.contentLength == 5) 33 | } 34 | 35 | @Test("explicit MultipartFormPart.dataPart sets all properties") 36 | func test_MultipartFormPartData_setsProperties() { 37 | let payload = "value".data(using: .utf8)! 38 | let part = MultipartFormPart.dataPart(name: "field", 39 | data: payload, 40 | mimeType: .json) 41 | #expect(part.name == "field") 42 | #expect(part.filename == nil) 43 | #expect(part.mimeType == .json) 44 | #expect(part.data == payload) 45 | #expect(part.contentLength == 5) 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Other/Extensions/Data-ext.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Data { 4 | /// Create `Data` from a UTF-8 `String`. 5 | init?(string: String) { 6 | guard let data = string.data(using: .utf8) else { return nil } 7 | self = data 8 | } 9 | 10 | /// Create `Data` by serializing a dictionary to JSON. 11 | init?(dictionary: [String: Any]) { 12 | guard let data = try? JSONSerialization.data(withJSONObject: dictionary, options: []) else { return nil } 13 | self = data 14 | } 15 | 16 | /// Encode an `Encodable` value to JSON `Data`. 17 | init?(encodable: T, encoder: JSONEncoder = JSONEncoder()) { 18 | guard let data = try? encoder.encode(encodable) else { return nil } 19 | self = data 20 | } 21 | 22 | /// Read file contents into `Data`. 23 | init?(fileURL url: URL) { 24 | guard let data = try? Data(contentsOf: url) else { return nil } 25 | self = data 26 | } 27 | 28 | /// Create `Data` from a JSON-formatted `String`. 29 | init?(jsonString: String) { 30 | self.init(string: jsonString) 31 | } 32 | 33 | /// Create `Data` from a Base64 encoded string. 34 | init?(base64: String) { 35 | guard let data = Data(base64Encoded: base64) else { return nil } 36 | self = data 37 | } 38 | 39 | /// Create `Data` from URLComponents' percent-encoded query. 40 | init?(urlComponents: URLComponents) { 41 | guard let query = urlComponents.percentEncodedQuery, 42 | let data = query.data(using: .utf8) else { return nil } 43 | self = data 44 | } 45 | 46 | /// Create `Data` from MultipartFormData 47 | init?(multipartFormData: MultipartFormData) { 48 | guard let data = multipartFormData.toData() else { return nil } 49 | self = data 50 | } 51 | } 52 | 53 | public extension Data { 54 | func appending(_ data: Data?) -> Data { 55 | guard let dataToAppend = data else { return self } 56 | var copy = self 57 | copy.append(dataToAppend) 58 | return copy 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Validator/ResponseValidator.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol ResponseValidator { 4 | func validateNoError(_ error: Error?) throws 5 | func validateStatus(from urlResponse: URLResponse?) throws 6 | func validateData(_ data: Data?) throws -> Data 7 | func validateUrl(_ url: URL?) throws -> URL 8 | } 9 | 10 | public struct ResponseValidatorImpl: ResponseValidator { 11 | public init() {} 12 | 13 | public func validateNoError(_ error: Error?) throws { 14 | if let error = error { 15 | if let urlError = error as? URLError { 16 | throw NetworkingError.urlError(urlError) 17 | } 18 | throw NetworkingError.internalError(.requestFailed(error)) 19 | } 20 | } 21 | 22 | public func validateStatus(from urlResponse: URLResponse?) throws { 23 | guard let urlResponse else { 24 | throw NetworkingError.internalError(.noResponse) 25 | } 26 | guard let httpURLResponse = urlResponse as? HTTPURLResponse else { 27 | throw NetworkingError.internalError(.noHTTPURLResponse) 28 | } 29 | try validateStatusCodeAccepability(from: httpURLResponse) 30 | } 31 | 32 | public func validateData(_ data: Data?) throws -> Data { 33 | guard let data else { 34 | throw NetworkingError.internalError(.noData) 35 | } 36 | return data 37 | } 38 | 39 | public func validateUrl(_ url: URL?) throws -> URL { 40 | guard let url else { 41 | throw NetworkingError.internalError(.noURL) 42 | } 43 | return url 44 | } 45 | 46 | private func validateStatusCodeAccepability(from httpURLResponse: HTTPURLResponse) throws { 47 | let statusCode = HTTPError(statusCode: httpURLResponse.statusCode, 48 | headers: httpURLResponse.allHeaderFields) 49 | 50 | if statusCode.category == .success { 51 | return // successful http response (2xx) do not throw error 52 | } 53 | throw NetworkingError.httpError(statusCode) 54 | } 55 | } 56 | 57 | public typealias URLResponseHeaders = [AnyHashable: Any] 58 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class SessionDelegate: NSObject { 4 | public weak var cacheInterceptor: CacheInterceptor? = nil 5 | public weak var authenticationInterceptor: AuthenticationInterceptor? = nil 6 | public weak var redirectInterceptor: RedirectInterceptor? = nil 7 | public weak var metricsInterceptor: MetricsInterceptor? = nil 8 | public weak var taskLifecycleInterceptor: TaskLifecycleInterceptor? = nil 9 | public weak var dataTaskInterceptor: DataTaskInterceptor? = nil 10 | public weak var downloadTaskInterceptor: DownloadTaskInterceptor? = nil 11 | public weak var uploadTaskInterceptor: UploadTaskInterceptor? = nil 12 | public weak var streamTaskInterceptor: StreamTaskInterceptor? = nil 13 | public weak var webSocketTaskInterceptor: WebSocketTaskInterceptor? = nil 14 | 15 | public init(cacheInterceptor: CacheInterceptor? = nil, 16 | authenticationInterceptor: AuthenticationInterceptor? = nil, 17 | redirectInterceptor: RedirectInterceptor? = nil, 18 | metricsInterceptor: MetricsInterceptor? = nil, 19 | taskLifecycleInterceptor: TaskLifecycleInterceptor? = nil, 20 | dataTaskInterceptor: DataTaskInterceptor? = nil, 21 | downloadTaskInterceptor: DownloadTaskInterceptor? = nil, 22 | uploadTaskInterceptor: UploadTaskInterceptor? = nil, 23 | streamTaskInterceptor: StreamTaskInterceptor? = nil, 24 | webSocketTaskInterceptor: WebSocketTaskInterceptor? = nil) { 25 | self.cacheInterceptor = cacheInterceptor 26 | self.authenticationInterceptor = authenticationInterceptor 27 | self.redirectInterceptor = redirectInterceptor 28 | self.metricsInterceptor = metricsInterceptor 29 | self.taskLifecycleInterceptor = taskLifecycleInterceptor 30 | self.dataTaskInterceptor = dataTaskInterceptor 31 | self.downloadTaskInterceptor = downloadTaskInterceptor 32 | self.uploadTaskInterceptor = uploadTaskInterceptor 33 | self.streamTaskInterceptor = streamTaskInterceptor 34 | self.webSocketTaskInterceptor = webSocketTaskInterceptor 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/WebSocket/Helpers/WebSocketConnectionStateTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test WebSocketConnectionState") 6 | final class WebSocketConnectionStateTests { 7 | @Test("basic equality for simple states") 8 | func testBasicEquality() { 9 | #expect(WebSocketConnectionState.disconnected == .disconnected) 10 | #expect(WebSocketConnectionState.connecting == .connecting) 11 | #expect(WebSocketConnectionState.idle == .idle) 12 | } 13 | 14 | @Test("connected state equality with protocol values") 15 | func testConnectedEquality() { 16 | #expect(WebSocketConnectionState.connected(protocol: nil) == .connected(protocol: nil)) 17 | #expect(WebSocketConnectionState.connected(protocol: "chat") == .connected(protocol: "chat")) 18 | #expect(WebSocketConnectionState.connected(protocol: "chat") != .connected(protocol: "video")) 19 | #expect(WebSocketConnectionState.connected(protocol: nil) != .connected(protocol: "chat")) 20 | } 21 | 22 | @Test("connectionLost and failed equality by underlying WebSocketError") 23 | func testErrorAssociatedEquality() { 24 | let err1 = WebSocketError.connectionFailed(underlying: URLError(.notConnectedToInternet)) 25 | let err2 = WebSocketError.connectionFailed(underlying: URLError(.notConnectedToInternet)) 26 | let other = WebSocketError.connectionFailed(underlying: NSError(domain: "x", code: 1)) 27 | 28 | #expect(WebSocketConnectionState.connectionLost(reason: err1) == .connectionLost(reason: err2)) 29 | #expect(WebSocketConnectionState.connectionLost(reason: err1) != .connectionLost(reason: other)) 30 | 31 | #expect(WebSocketConnectionState.failed(error: err1) == .failed(error: err2)) 32 | #expect(WebSocketConnectionState.failed(error: err1) != .failed(error: other)) 33 | } 34 | 35 | @Test("different enum cases are not equal") 36 | func testDifferentCasesNotEqual() { 37 | #expect(WebSocketConnectionState.disconnected != .connecting) 38 | #expect(WebSocketConnectionState.disconnected != .connected(protocol: nil)) 39 | #expect(WebSocketConnectionState.connecting != .connected(protocol: "p")) 40 | #expect(WebSocketConnectionState.connectionLost(reason: WebSocketError.taskCancelled) != .failed(error: WebSocketError.taskCancelled)) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Body/MultipartFormData/MultipartFormPart.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Represents a single body part in a `multipart/form-data` HTTP request. 4 | public struct MultipartFormPart { 5 | /// The form field name associated with this part. 6 | public var name: String 7 | 8 | /// The raw binary data representing the body of this part. 9 | public var data: Data 10 | 11 | /// An optional filename to include in the `Content-Disposition` header. 12 | public var filename: String? 13 | 14 | /// The MIME type describing the content of the data payload. 15 | public var mimeType: MimeType 16 | 17 | /// The total size of the data payload, in bytes. 18 | public var contentLength: Int { data.count } 19 | 20 | /// The payload interpreted as a UTF-8 string. 21 | public var value: String? { 22 | get { String(data: data, encoding: .utf8) } 23 | set { data = newValue.map { Data($0.utf8) } ?? Data() } 24 | } 25 | 26 | // MARK: - Internal Initializers 27 | 28 | /// Creates a new multipart form part with binary data. 29 | internal init(name: String, data: Data, filename: String? = nil, mimeType: MimeType) { 30 | self.name = name 31 | self.data = data 32 | self.filename = filename 33 | self.mimeType = mimeType 34 | } 35 | 36 | /// Creates a new multipart form part from a plain text value. 37 | internal init(name: String, value: String) { 38 | self.init(name: name, data: Data(value.utf8), filename: nil, mimeType: .plain) 39 | } 40 | } 41 | 42 | // MARK: - Creation static methods 43 | 44 | public extension MultipartFormPart { 45 | /// Creates a text field part with a UTF-8 encoded value. 46 | static func fieldPart(name: String, value: String) -> MultipartFormPart { 47 | MultipartFormPart(name: name, value: value) 48 | } 49 | 50 | /// Creates a file upload part with binary data and metadata. 51 | static func filePart(name: String, data: Data, filename: String, mimeType: MimeType) -> MultipartFormPart { 52 | MultipartFormPart(name: name, data: data, filename: filename, mimeType: mimeType) 53 | } 54 | 55 | /// Creates a data-only part (no filename) with the given MIME type, useful for raw blobs or non-file fields. 56 | static func dataPart(name: String, data: Data, mimeType: MimeType) -> MultipartFormPart { 57 | MultipartFormPart(name: name, data: data, filename: nil, mimeType: mimeType) 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Headers/HTTPHeader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum HTTPHeader: Equatable { 4 | case accept(MimeType) 5 | case acceptCharset(String) 6 | case acceptEncoding(String) 7 | case acceptLanguage(String) 8 | case authorization(AuthorizationType) 9 | case cacheControl(String) 10 | case contentLength(String) 11 | case contentType(MimeType) 12 | case cookie(String) 13 | case host(String) 14 | case ifModifiedSince(String) 15 | case ifNoneMatch(String) 16 | case origin(String) 17 | case referer(String) 18 | case userAgent(String) 19 | case custom(key: String, value: String) 20 | 21 | var key: String { 22 | switch self { 23 | case .accept: return "Accept" 24 | case .acceptCharset: return "Accept-Charset" 25 | case .acceptEncoding: return "Accept-Encoding" 26 | case .acceptLanguage: return "Accept-Language" 27 | case .authorization: return "Authorization" 28 | case .cacheControl: return "Cache-Control" 29 | case .contentLength: return "Content-Length" 30 | case .contentType: return "Content-Type" 31 | case .cookie: return "Cookie" 32 | case .host: return "Host" 33 | case .ifModifiedSince: return "If-Modified-Since" 34 | case .ifNoneMatch: return "If-None-Match" 35 | case .origin: return "Origin" 36 | case .referer: return "Referer" 37 | case .userAgent: return "User-Agent" 38 | case .custom(let key, _): return key 39 | } 40 | } 41 | 42 | var value: String { 43 | switch self { 44 | case .accept(let accept): return accept.value 45 | case .acceptCharset(let value): return value 46 | case .acceptEncoding(let value): return value 47 | case .acceptLanguage(let value): return value 48 | case .authorization(let authentication): return authentication.value 49 | case .cacheControl(let value): return value 50 | case .contentLength(let value): return value 51 | case .contentType(let contentType): return contentType.value 52 | case .cookie(let value): return value 53 | case .host(let value): return value 54 | case .ifModifiedSince(let value): return value 55 | case .ifNoneMatch(let value): return value 56 | case .origin(let value): return value 57 | case .referer(let value): return value 58 | case .userAgent(let value): return value 59 | case .custom(_, let value): return value 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Interceptors/SessionDelegate+Extensions/SessionDelegate+URLSessionTaskDelegate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension SessionDelegate: URLSessionTaskDelegate { 4 | // Async authentication 5 | public func urlSession(_ session: URLSession, 6 | task: URLSessionTask, 7 | didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 8 | await authenticationInterceptor?.urlSession(session, task: task, didReceive: challenge) ?? (.performDefaultHandling, nil) 9 | } 10 | 11 | // Redirect Interception 12 | public func urlSession(_ session: URLSession, 13 | task: URLSessionTask, 14 | willPerformHTTPRedirection response: HTTPURLResponse, 15 | newRequest request: URLRequest) async -> URLRequest? { 16 | await redirectInterceptor?.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: request) ?? request 17 | } 18 | 19 | // Metrics Interception 20 | public func urlSession(_ session: URLSession, 21 | task: URLSessionTask, 22 | didFinishCollecting metrics: URLSessionTaskMetrics) { 23 | metricsInterceptor?.urlSession(session, task: task, didFinishCollecting: metrics) 24 | } 25 | 26 | // Task Lifecycle Interception 27 | public func urlSession(_ session: URLSession, 28 | task: URLSessionTask, 29 | didCompleteWithError error: Error?) { 30 | taskLifecycleInterceptor?.urlSession(session, task: task, didCompleteWithError: error) 31 | 32 | if let error = error { 33 | webSocketTaskInterceptor?.urlSession(session, task: task, didCompleteWithError: error) 34 | } 35 | } 36 | 37 | public func urlSession(_ session: URLSession, 38 | taskIsWaitingForConnectivity task: URLSessionTask) { 39 | taskLifecycleInterceptor?.urlSession(session, taskIsWaitingForConnectivity: task) 40 | } 41 | 42 | public func urlSession(_ session: URLSession, 43 | task: URLSessionTask, 44 | didSendBodyData bytesSent: Int64, 45 | totalBytesSent: Int64, 46 | totalBytesExpectedToSend: Int64) { 47 | uploadTaskInterceptor?.urlSession(session, task: task, didSendBodyData: bytesSent, totalBytesSent: totalBytesSent, totalBytesExpectedToSend: totalBytesExpectedToSend) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Request/RequestBuilderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test RequestBuilder") 6 | final class RequestBuilderTests { 7 | 8 | @Test("test BuildURLRequest with valid parameters") 9 | func testBuildURLRequestWithValidParameters() { 10 | let builder = RequestBuilderImpl() 11 | let urlString = "https://example.com/api" 12 | let httpMethod = HTTPMethod.POST 13 | let parameters: [HTTPParameter] = [ 14 | HTTPParameter(key: "key1", value: "value1"), 15 | HTTPParameter(key: "key2", value: "value2") 16 | ] 17 | let headers: [HTTPHeader] = [ 18 | HTTPHeader.contentType(.json), 19 | HTTPHeader.authorization(.bearer("token")) 20 | ] 21 | let body = Data(jsonString: "{\"name\": \"John\"}")! 22 | let timeoutInterval: TimeInterval = 30 23 | 24 | let request = builder 25 | .setHttpMethod(httpMethod) 26 | .setBaseUrl(urlString) 27 | .setParameters(parameters) 28 | .setHeaders(headers) 29 | .setBody(body) 30 | .setTimeoutInterval(timeoutInterval) 31 | .build() 32 | 33 | #expect(request != nil) 34 | #expect(request?.baseUrlString == "https://example.com/api") 35 | #expect(request?.httpMethod == httpMethod) 36 | #expect(request?.parameters == parameters) 37 | #expect(request?.body?.toData() == body) 38 | #expect(request?.timeoutInterval == timeoutInterval) 39 | #expect(request?.headers == headers) 40 | #expect(request?.cachePolicy == .useProtocolCachePolicy) 41 | } 42 | 43 | @Test("test BuildURLRequest with no parameters and headers") 44 | func testBuildURLRequestWithNoParametersAndHeaders() { 45 | let builder = RequestBuilderImpl() 46 | let urlString = "https://example.com/api" 47 | let httpMethod = HTTPMethod.PUT 48 | 49 | let request = builder 50 | .setHttpMethod(httpMethod) 51 | .setBaseUrl(urlString) 52 | .setTimeoutInterval(60) 53 | .build() 54 | 55 | #expect(request != nil) 56 | #expect(request?.baseUrlString == "https://example.com/api") 57 | #expect(request?.httpMethod == httpMethod) 58 | #expect(request?.parameters == nil) 59 | #expect(request?.body == nil) 60 | #expect(request?.timeoutInterval == 60) 61 | #expect(request?.headers == nil) 62 | #expect(request?.cachePolicy == .useProtocolCachePolicy) 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Body/MultipartFormData/MultipartFormData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public class MultipartFormData: DataConvertible { 4 | 5 | // MARK: - Helper Types 6 | 7 | enum Constants { 8 | static let crlf = "\r\n" 9 | } 10 | 11 | enum BoundaryGenerator { 12 | enum BoundaryType { 13 | case initial, encapsulated, final 14 | } 15 | 16 | static func boundaryData(forBoundaryType boundaryType: BoundaryType, boundary: String) -> Data { 17 | let boundaryText = switch boundaryType { 18 | case .initial: 19 | "--\(boundary)\(Constants.crlf)" 20 | case .encapsulated: 21 | "\(Constants.crlf)--\(boundary)\(Constants.crlf)" 22 | case .final: 23 | "\(Constants.crlf)--\(boundary)--\(Constants.crlf)" 24 | } 25 | return Data(boundaryText.utf8) 26 | } 27 | } 28 | 29 | // MARK: Variables 30 | 31 | public let parts: [MultipartFormPart] 32 | public let boundary: String 33 | 34 | // MARK: Init 35 | 36 | public init(parts: [MultipartFormPart], boundary: String) { 37 | self.parts = parts 38 | self.boundary = boundary 39 | } 40 | 41 | // MARK: Data 42 | 43 | public func toData() -> Data? { 44 | var data = Data() 45 | 46 | // 1️⃣ Append initial boundary 47 | data.append(BoundaryGenerator.boundaryData(forBoundaryType: .initial, boundary: boundary)) 48 | 49 | // 2️⃣ Append each form part 50 | for (index, part) in parts.enumerated() { 51 | var headers = "Content-Disposition: form-data; name=\"\(part.name)\"" 52 | 53 | // Include filename if this part is a file 54 | if let filename = part.filename { 55 | headers += "; filename=\"\(filename)\"" 56 | } 57 | 58 | headers += Constants.crlf 59 | 60 | // Include Content-Type header if present 61 | headers += "Content-Type: \(part.mimeType.value)\(Constants.crlf)\(Constants.crlf)" 62 | 63 | // Add headers as Data 64 | data.append(Data(headers.utf8)) 65 | 66 | // Add the actual data payload 67 | data.append(part.data) 68 | 69 | // If not the last part, add an encapsulated boundary 70 | if index < parts.count - 1 { 71 | data.append(BoundaryGenerator.boundaryData(forBoundaryType: .encapsulated, boundary: boundary)) 72 | } 73 | } 74 | 75 | // 3️⃣ Append final boundary 76 | data.append(BoundaryGenerator.boundaryData(forBoundaryType: .final, boundary: boundary)) 77 | 78 | return data 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Request/RequestBuilder.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol RequestBuilder { 4 | func setHttpMethod(_ method: HTTPMethod) -> RequestBuilder 5 | func setBaseUrl(_ baseUrl: String) -> RequestBuilder 6 | func setParameters(_ parameters: [HTTPParameter]) -> RequestBuilder 7 | func setHeaders(_ headers: [HTTPHeader]) -> RequestBuilder 8 | func setBody(_ body: HTTPBody) -> RequestBuilder 9 | func setTimeoutInterval(_ timeoutInterval: TimeInterval) -> RequestBuilder 10 | func setCachePolicy(_ cachePolicy: URLRequest.CachePolicy) -> RequestBuilder 11 | func build() -> Request? 12 | } 13 | 14 | public class RequestBuilderImpl: RequestBuilder { 15 | private var httpMethod: HTTPMethod? 16 | private var baseUrlString: String? 17 | private var parameters: [HTTPParameter]? 18 | private var headers: [HTTPHeader]? 19 | private var body: HTTPBody? = nil 20 | private var timeoutInterval: TimeInterval? 21 | private var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy 22 | 23 | private let headerEncoder: HTTPHeaderEncoder 24 | private let paramEncoder: HTTPParameterEncoder 25 | 26 | public init(headerEncoder: HTTPHeaderEncoder = HTTPHeaderEncoderImpl(), 27 | paramEncoder: HTTPParameterEncoder = HTTPParameterEncoderImpl()) { 28 | self.headerEncoder = headerEncoder 29 | self.paramEncoder = paramEncoder 30 | } 31 | 32 | public func setHttpMethod(_ method: HTTPMethod) -> RequestBuilder { 33 | self.httpMethod = method 34 | return self 35 | } 36 | 37 | public func setBaseUrl(_ baseUrl: String) -> RequestBuilder { 38 | self.baseUrlString = baseUrl 39 | return self 40 | } 41 | 42 | public func setParameters(_ parameters: [HTTPParameter]) -> RequestBuilder { 43 | self.parameters = parameters 44 | return self 45 | } 46 | 47 | public func setHeaders(_ headers: [HTTPHeader]) -> RequestBuilder { 48 | self.headers = headers 49 | return self 50 | } 51 | 52 | public func setBody(_ body: HTTPBody) -> RequestBuilder { 53 | self.body = body 54 | return self 55 | } 56 | 57 | public func setTimeoutInterval(_ timeoutInterval: TimeInterval) -> RequestBuilder { 58 | self.timeoutInterval = timeoutInterval 59 | return self 60 | } 61 | 62 | public func setCachePolicy(_ cachePolicy: URLRequest.CachePolicy) -> RequestBuilder { 63 | self.cachePolicy = cachePolicy 64 | return self 65 | } 66 | 67 | public func build() -> Request? { 68 | guard let httpMethod, let baseUrlString else { return nil } 69 | return EZRequest(httpMethod: httpMethod, baseUrlString: baseUrlString, parameters: parameters, headers: headers, body: body, timeoutInterval: timeoutInterval ?? 60, cachePolicy: cachePolicy) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Request/RequestFactoryTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test RequestFactory") 6 | final class RequestFactoryTests { 7 | 8 | @Test("test BuildURLRequest with valid parameters") 9 | func testBuildURLRequestWithValidParameters() { 10 | let builder = RequestFactoryImpl() 11 | let urlString = "https://example.com/api" 12 | let httpMethod = HTTPMethod.POST 13 | let parameters: [HTTPParameter] = [ 14 | HTTPParameter(key: "key1", value: "value1"), 15 | HTTPParameter(key: "key2", value: "value2") 16 | ] 17 | let headers: [HTTPHeader] = [ 18 | HTTPHeader.contentType(.json), 19 | HTTPHeader.authorization(.bearer("token")) 20 | ] 21 | let body = Data(jsonString: "{\"name\": \"John\"}") 22 | let timeoutInterval: TimeInterval = 30 23 | 24 | let request = builder.build(httpMethod: httpMethod, 25 | baseUrlString: urlString, 26 | parameters: parameters, 27 | headers: headers, 28 | body: body, 29 | timeoutInterval: timeoutInterval) 30 | 31 | #expect(request != nil) 32 | #expect(request.baseUrlString == "https://example.com/api") 33 | #expect(request.httpMethod == httpMethod) 34 | #expect(request.parameters == parameters) 35 | #expect(request.body?.toData() == body) 36 | #expect(request.timeoutInterval == timeoutInterval) 37 | #expect(request.headers == headers) 38 | #expect(request.cachePolicy == .useProtocolCachePolicy) 39 | } 40 | 41 | @Test("test BuildURLRequest with no parameters and headers") 42 | func testBuildURLRequestWithNoParametersAndHeaders() { 43 | let builder = RequestFactoryImpl() 44 | let urlString = "https://example.com/api" 45 | let httpMethod = HTTPMethod.PUT 46 | 47 | let request = builder.build(httpMethod: httpMethod, 48 | baseUrlString: urlString, 49 | parameters: nil, 50 | headers: nil, 51 | body: nil, 52 | timeoutInterval: 60) 53 | 54 | #expect(request != nil) 55 | #expect(request.baseUrlString == "https://example.com/api") 56 | #expect(request.httpMethod == httpMethod) 57 | #expect(request.parameters == nil) 58 | #expect(request.body == nil) 59 | #expect(request.timeoutInterval == 60) 60 | #expect(request.headers == nil) 61 | #expect(request.cachePolicy == .useProtocolCachePolicy) 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/WebSocket/Mocks/MockURLSessionWebSocketTask.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockURLSessionWebSocketTask: WebSocketTaskProtocol { 5 | 6 | init(resumeClosure: @escaping (() -> Void) = {}, 7 | sendThrowsError: Bool = false, 8 | receiveMessage: String = "", 9 | receiveThrowsError: Bool = false 10 | ) { 11 | self.resumeClosure = resumeClosure 12 | self.sendThrowsError = sendThrowsError 13 | self.receiveMessage = receiveMessage 14 | self.receiveThrowsError = receiveThrowsError 15 | } 16 | 17 | // MARK: resume() 18 | 19 | var resumeClosure: () -> Void 20 | var didCallResume = false 21 | func resume() { 22 | didCallResume = true 23 | resumeClosure() 24 | } 25 | 26 | // MARK: cancel() 27 | 28 | var didCallCancel = false 29 | var didCancelWithCloseCode: URLSessionWebSocketTask.CloseCode? 30 | var didCancelWithReason: Data? 31 | func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { 32 | didCallCancel = true 33 | didCancelWithCloseCode = closeCode 34 | didCancelWithReason = reason 35 | } 36 | 37 | // MARK: send() 38 | 39 | var sendThrowsError: Bool 40 | func send(_ message: URLSessionWebSocketTask.Message, completionHandler: @escaping @Sendable ((any Error)?) -> Void) { 41 | if sendThrowsError { 42 | completionHandler(MockURLSessionWebSocketTaskError.failedToSendMessage) 43 | } 44 | } 45 | 46 | func send(_ message: URLSessionWebSocketTask.Message) async throws { 47 | if sendThrowsError { 48 | throw MockURLSessionWebSocketTaskError.failedToSendMessage 49 | } 50 | } 51 | 52 | // MARK: receive() 53 | 54 | var receiveMessage: String 55 | var receiveThrowsError: Bool 56 | func receive(completionHandler: @escaping @Sendable (Result) -> Void) { 57 | if receiveThrowsError { 58 | completionHandler(.failure(MockURLSessionWebSocketTaskError.failedToReceiveMessage)) 59 | } else { 60 | completionHandler(.success(.string(receiveMessage))) 61 | } 62 | } 63 | 64 | func receive() async throws -> URLSessionWebSocketTask.Message { 65 | if receiveThrowsError { 66 | throw MockURLSessionWebSocketTaskError.failedToReceiveMessage 67 | } 68 | return .string(receiveMessage) 69 | } 70 | 71 | // MARK: sendPing 72 | 73 | var shouldFailPing: Bool = false 74 | var pingFailureCount: Int = 0 75 | func sendPing(pongReceiveHandler: @escaping @Sendable ((any Error)?) -> Void) { 76 | if shouldFailPing { 77 | pingFailureCount += 1 78 | pongReceiveHandler(MockURLSessionWebSocketTaskError.pingError) 79 | } else { 80 | pongReceiveHandler(nil) 81 | } 82 | } 83 | } 84 | 85 | enum MockURLSessionWebSocketTaskError: Error { 86 | case pingError 87 | case failedToSendMessage 88 | case failedToReceiveMessage 89 | } 90 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/HTTP/Headers/HTTPHeaderTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPHeader") 5 | final class HTTPHeaderTests { 6 | 7 | @Test("test HTTPHeader .key and .value", arguments: arguments) 8 | func testHTTPHeaderKeyAndValue(header: HTTPHeader, key: String, value: String) { 9 | #expect(header.key == key) 10 | #expect(header.value == value) 11 | } 12 | 13 | private static let arguments: [(header: HTTPHeader, key: String, value: String)] = [ 14 | (header: HTTPHeader.accept(.json), key: "Accept", value: "application/json"), 15 | (header: HTTPHeader.accept(.xml), key: "Accept", value: "application/xml"), 16 | (header: HTTPHeader.accept(.formUrlEncoded), key: "Accept", value: "application/x-www-form-urlencoded"), 17 | (header: HTTPHeader.accept(.custom("custom_value")), key: "Accept", value: "custom_value"), 18 | (header: HTTPHeader.acceptCharset("utf-8"), key: "Accept-Charset", value: "utf-8"), 19 | (header: HTTPHeader.acceptEncoding("gzip, deflate"), key: "Accept-Encoding", value: "gzip, deflate"), 20 | (header: HTTPHeader.acceptLanguage("en-US"), key: "Accept-Language", value: "en-US"), 21 | (header: HTTPHeader.authorization(.bearer("abcde")), key: "Authorization", value: "Bearer abcde"), 22 | (header: HTTPHeader.cacheControl("no-cache"), key: "Cache-Control", value: "no-cache"), 23 | (header: HTTPHeader.contentLength("1024"), key: "Content-Length", value: "1024"), 24 | (header: HTTPHeader.contentType(.json), key: "Content-Type", value: "application/json"), 25 | (header: HTTPHeader.contentType(.xml), key: "Content-Type", value: "application/xml"), 26 | (header: HTTPHeader.contentType(.formUrlEncoded), key: "Content-Type", value: "application/x-www-form-urlencoded"), 27 | (header: HTTPHeader.contentType(.custom("custom_value")), key: "Content-Type", value: "custom_value"), 28 | (header: HTTPHeader.cookie("session_id=abcdef123456"), key: "Cookie", value: "session_id=abcdef123456"), 29 | (header: HTTPHeader.host("example.com"), key: "Host", value: "example.com"), 30 | (header: HTTPHeader.ifModifiedSince(sampleDate), key: "If-Modified-Since", value: sampleDate), 31 | (header: HTTPHeader.ifNoneMatch("W/\"123456789\""), key: "If-None-Match", value: "W/\"123456789\""), 32 | (header: HTTPHeader.origin("https://example.com"), key: "Origin", value: "https://example.com"), 33 | (header: HTTPHeader.referer("https://example.com/previous-page"), key: "Referer", value: "https://example.com/previous-page"), 34 | (header: HTTPHeader.userAgent(sampleUserAgent), key: "User-Agent", value: sampleUserAgent), 35 | (header: HTTPHeader.custom(key: "X-Custom-Header", value: "custom-value"), key: "X-Custom-Header", value: "custom-value") 36 | ] 37 | 38 | private static let sampleDate = "Tue, 21 Jul 2024 00:00:00 GMT" 39 | private static let sampleUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" 40 | } 41 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Interceptors/SessoionDelegate+Extensions+Tests/SessionDelegate+URLSessionDownloadDelegate+Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test SessionDelegateURLSessionDownloadDelegate") 6 | final class SessionDelegateURLSessionDownloadDelegateTests { 7 | 8 | @Test("test SessionDelegate DidFinishDownloadingTo") 9 | func testSessionDelegateDidFinishDownloadingTo() { 10 | let downloadTaskInterceptor = MockDownloadTaskInterceptor() 11 | let delegate = SessionDelegate() 12 | delegate.downloadTaskInterceptor = downloadTaskInterceptor 13 | 14 | let mockURL = URL(fileURLWithPath: "/tmp/mockFile") 15 | delegate.urlSession(.shared, downloadTask: mockUrlSessionDownloadTask, didFinishDownloadingTo: mockURL) 16 | 17 | #expect(downloadTaskInterceptor.didFinishDownloading) 18 | } 19 | 20 | @Test("test SessionDelegat eDidWriteData") 21 | func testSessionDelegateDidWriteData() { 22 | let downloadTaskInterceptor = MockDownloadTaskInterceptor() 23 | let delegate = SessionDelegate() 24 | delegate.downloadTaskInterceptor = downloadTaskInterceptor 25 | 26 | delegate.urlSession(.shared, downloadTask: mockUrlSessionDownloadTask, didWriteData: 100, totalBytesWritten: 200, totalBytesExpectedToWrite: 1000) 27 | 28 | #expect(downloadTaskInterceptor.didWriteData) 29 | } 30 | 31 | @Test("test SessionDelegate DidResumeAtOffset") 32 | func testSessionDelegateDidResumeAtOffset() { 33 | let downloadTaskInterceptor = MockDownloadTaskInterceptor() 34 | let delegate = SessionDelegate() 35 | delegate.downloadTaskInterceptor = downloadTaskInterceptor 36 | 37 | delegate.urlSession(.shared, downloadTask: mockUrlSessionDownloadTask, didResumeAtOffset: 500, expectedTotalBytes: 1000) 38 | 39 | #expect(downloadTaskInterceptor.didResumeAtOffset) 40 | } 41 | 42 | } 43 | // MARK: mock class 44 | 45 | private class MockDownloadTaskInterceptor: DownloadTaskInterceptor { 46 | var progress: (Double) -> Void = { _ in } 47 | 48 | var didFinishDownloading = false 49 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 50 | didFinishDownloading = true 51 | } 52 | 53 | var didWriteData = false 54 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { 55 | didWriteData = true 56 | } 57 | 58 | var didResumeAtOffset = false 59 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didResumeAtOffset fileOffset: Int64, expectedTotalBytes: Int64) { 60 | didResumeAtOffset = true 61 | } 62 | } 63 | 64 | // MARK: mock variables 65 | 66 | private var mockUrlSessionDownloadTask: URLSessionDownloadTask { 67 | URLSession.shared.downloadTask(with: URLRequest(url: URL(string: "https://www.example.com")!)) 68 | } 69 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Downloader/Mocks/MockFileDownloaderURLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockFileDownloaderURLSession: URLSessionTaskProtocol { 5 | var url: URL? 6 | var data: Data? 7 | var urlResponse: URLResponse? 8 | var error: Error? 9 | var completion: ((Data?, URLResponse?, Error?) -> Void)? 10 | var sessionDelegate: SessionDelegate? = nil 11 | 12 | var progressToExecute: [DownloadProgress] = [] 13 | 14 | init(data: Data? = nil, url: URL? = nil, urlResponse: URLResponse? = nil, error: Error? = nil) { 15 | self.data = data 16 | self.url = url 17 | self.urlResponse = urlResponse 18 | self.error = error 19 | } 20 | 21 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, Error?) -> Void) -> URLSessionDownloadTask { 22 | 23 | simulateDownloadProgress(for: .init()) 24 | 25 | return MockURLSessionDownloadTask { 26 | completionHandler(URL(fileURLWithPath: "/tmp/test.pdf"), self.urlResponse, self.error) 27 | } 28 | } 29 | 30 | } 31 | 32 | // MARK: Helpers 33 | 34 | extension MockFileDownloaderURLSession { 35 | enum DownloadProgress { 36 | case inProgress(percent: Int64) 37 | case complete 38 | } 39 | 40 | private func simulateDownloadProgress(for task: URLSessionDownloadTask) { 41 | 42 | for progressToExecute in self.progressToExecute { 43 | switch progressToExecute { 44 | case .inProgress(let percent): 45 | // Simulate x% progress 46 | sessionDelegate?.urlSession( 47 | .shared, 48 | downloadTask: task, 49 | didWriteData: 0, 50 | totalBytesWritten: percent, 51 | totalBytesExpectedToWrite: 100 52 | ) 53 | 54 | case .complete: 55 | // Simulate completion 56 | sessionDelegate?.urlSession( 57 | .shared, 58 | downloadTask: task, 59 | didFinishDownloadingTo: URL(fileURLWithPath: "/tmp/test.pdf") 60 | ) 61 | } 62 | } 63 | } 64 | } 65 | 66 | // MARK: unused methods 67 | 68 | extension MockFileDownloaderURLSession { 69 | func dataTask(with request: URLRequest, completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void) -> URLSessionDataTask { 70 | fatalError("Should not be using in this mock") 71 | } 72 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 73 | fatalError("Should not be using in this mock") 74 | } 75 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 76 | fatalError("Should not be using in this mock") 77 | } 78 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 79 | fatalError("Should not be using in this mock") 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Uploader/DataUploader/Mocks/MockDataUploaderURLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockDataUploaderURLSession: URLSessionTaskProtocol { 5 | var data: Data? 6 | var urlResponse: URLResponse? 7 | var error: Error? 8 | var completionHandler: ((Data?, URLResponse?, (any Error)?) -> Void)? 9 | 10 | var sessionDelegate: SessionDelegate? = nil 11 | var progressToExecute: [UploadProgress] = [] 12 | 13 | init(data: Data?, 14 | urlResponse: URLResponse? = nil, 15 | error: Error? = nil 16 | ) { 17 | self.data = data 18 | self.urlResponse = urlResponse 19 | self.error = error 20 | } 21 | 22 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 23 | self.completionHandler = completionHandler 24 | 25 | simulateDownloadProgress(for: .init()) 26 | 27 | return MockURLSessionUploadTask { 28 | completionHandler(self.data, self.urlResponse, self.error) 29 | } 30 | } 31 | } 32 | 33 | // MARK: Helpers 34 | 35 | extension MockDataUploaderURLSession { 36 | enum UploadProgress { 37 | case inProgress(percent: Int64) 38 | case complete 39 | } 40 | 41 | private func simulateDownloadProgress(for task: URLSessionDownloadTask) { 42 | 43 | for progressToExecute in self.progressToExecute { 44 | switch progressToExecute { 45 | case .inProgress(let percent): 46 | // Simulate x% progress 47 | sessionDelegate?.urlSession( 48 | .shared, 49 | task: task, 50 | didSendBodyData: 0, 51 | totalBytesSent: percent, 52 | totalBytesExpectedToSend: 100 53 | ) 54 | 55 | case .complete: 56 | // Simulate completion 57 | sessionDelegate?.urlSession( 58 | .shared, 59 | task: task, 60 | didSendBodyData: 0, 61 | totalBytesSent: 100, 62 | totalBytesExpectedToSend: 100 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | 69 | // MARK: unused methods 70 | 71 | extension MockDataUploaderURLSession { 72 | func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask { 73 | fatalError("Should not be using in this mock") 74 | } 75 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask { 76 | fatalError("Should not be using in this mock") 77 | } 78 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 79 | fatalError("Should not be using in this mock") 80 | } 81 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 82 | fatalError("Should not be using in this mock") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Uploader/FileUploader/Mocks/MockFileUploaderURLSession.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import EZNetworking 3 | 4 | class MockFileUploaderURLSession: URLSessionTaskProtocol { 5 | var data: Data? 6 | var urlResponse: URLResponse? 7 | var error: Error? 8 | var completionHandler: ((Data?, URLResponse?, (any Error)?) -> Void)? 9 | 10 | var sessionDelegate: SessionDelegate? = nil 11 | var progressToExecute: [UploadProgress] = [] 12 | 13 | init(data: Data?, 14 | urlResponse: URLResponse? = nil, 15 | error: Error? = nil 16 | ) { 17 | self.data = data 18 | self.urlResponse = urlResponse 19 | self.error = error 20 | } 21 | 22 | func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 23 | self.completionHandler = completionHandler 24 | 25 | simulateDownloadProgress(for: .init()) 26 | 27 | return MockURLSessionUploadTask { 28 | completionHandler(self.data, self.urlResponse, self.error) 29 | } 30 | } 31 | } 32 | 33 | // MARK: Helpers 34 | 35 | extension MockFileUploaderURLSession { 36 | enum UploadProgress { 37 | case inProgress(percent: Int64) 38 | case complete 39 | } 40 | 41 | private func simulateDownloadProgress(for task: URLSessionDownloadTask) { 42 | 43 | for progressToExecute in self.progressToExecute { 44 | switch progressToExecute { 45 | case .inProgress(let percent): 46 | // Simulate x% progress 47 | sessionDelegate?.urlSession( 48 | .shared, 49 | task: task, 50 | didSendBodyData: 0, 51 | totalBytesSent: percent, 52 | totalBytesExpectedToSend: 100 53 | ) 54 | 55 | case .complete: 56 | // Simulate completion 57 | sessionDelegate?.urlSession( 58 | .shared, 59 | task: task, 60 | didSendBodyData: 0, 61 | totalBytesSent: 100, 62 | totalBytesExpectedToSend: 100 63 | ) 64 | } 65 | } 66 | } 67 | } 68 | 69 | // MARK: unused methods 70 | 71 | extension MockFileUploaderURLSession { 72 | func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask { 73 | fatalError("Should not be using in this mock") 74 | } 75 | func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask { 76 | fatalError("Should not be using in this mock") 77 | } 78 | func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { 79 | fatalError("Should not be using in this mock") 80 | } 81 | func webSocketTaskInspectable(with request: URLRequest) -> WebSocketTaskProtocol { 82 | fatalError("Should not be using in this mock") 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Interceptors/SessoionDelegate+Extensions+Tests/SessionDelegate+URLSessionDelegate+Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test SessionDelegateURLSessionDelegate") 6 | final class SessionDelegateURLSessionDelegateTests { 7 | 8 | @Test("test SessionDelegate DidReceiveChallenge") 9 | func testSessionDelegateDidReceiveChallenge() async { 10 | let authenticationInterceptor = MockAuthenticationInterceptor() 11 | let delegate = SessionDelegate() 12 | delegate.authenticationInterceptor = authenticationInterceptor 13 | 14 | let (disposition, credential) = await delegate.urlSession(.shared, didReceive: URLAuthenticationChallenge()) 15 | #expect(disposition == .performDefaultHandling) 16 | #expect(credential == nil) 17 | #expect(authenticationInterceptor.didReceiveChallenge) 18 | } 19 | 20 | @Test("test SessionDelegate DidReceiveChallenge with no interceptor") 21 | func testSessionDelegateDidReceiveChallengeWithNoInterceptor() async { 22 | let delegate = SessionDelegate() 23 | 24 | let (disposition, credential) = await delegate.urlSession(.shared, didReceive: URLAuthenticationChallenge()) 25 | #expect(disposition == .performDefaultHandling) 26 | #expect(credential == nil) 27 | } 28 | 29 | @Test("test SessionDelegate DidCreateTask") 30 | func testSessionDelegateDidCreateTask() { 31 | let taskLifecycleInterceptor = MockTaskLifecycleInterceptor() 32 | let delegate = SessionDelegate() 33 | delegate.taskLifecycleInterceptor = taskLifecycleInterceptor 34 | 35 | delegate.urlSession(.shared, didCreateTask: mockUrlSessionDataTask) 36 | #expect(taskLifecycleInterceptor.didCreateTask) 37 | } 38 | } 39 | 40 | // MARK: mock classes 41 | 42 | private class MockAuthenticationInterceptor: AuthenticationInterceptor { 43 | var didReceiveChallengeWithTask = false 44 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 45 | didReceiveChallengeWithTask = true 46 | return (.performDefaultHandling, nil) 47 | } 48 | 49 | var didReceiveChallenge = false 50 | func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 51 | didReceiveChallenge = true 52 | return (.performDefaultHandling, nil) 53 | } 54 | } 55 | 56 | private class MockTaskLifecycleInterceptor: TaskLifecycleInterceptor { 57 | var didCompleteWithError = false 58 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { 59 | didCompleteWithError = true 60 | } 61 | 62 | var taskIsWaitingForConnectivity = false 63 | func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { 64 | taskIsWaitingForConnectivity = true 65 | } 66 | 67 | var didCreateTask = false 68 | func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { 69 | didCreateTask = true 70 | } 71 | } 72 | 73 | // MARK: mock variables 74 | 75 | private var mockUrlSessionDataTask: URLSessionDataTask { 76 | URLSession.shared.dataTask(with: URLRequest(url: URL(string: "https://www.example.com")!)) 77 | } 78 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Interceptors/SessoionDelegate+Extensions+Tests/SessionDelegate+URLSessionStreamDelegate+Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test SessionDelegateURLSessionStreamDelegate") 6 | final class SessionDelegateURLSessionStreamDelegateTests { 7 | 8 | @Test("test SessionDelegate ReadClosedForStreamTask") 9 | func testSessionDelegateReadClosedForStreamTask() { 10 | let streamTaskInterceptor = MockStreamTaskInterceptor() 11 | let delegate = SessionDelegate() 12 | delegate.streamTaskInterceptor = streamTaskInterceptor 13 | 14 | delegate.urlSession(.shared, readClosedFor: mockUrlSessionStreamTask) 15 | 16 | #expect(streamTaskInterceptor.readClosed) 17 | } 18 | 19 | @Test("test SessionDelegate WriteClosedForStreamTask") 20 | func testSessionDelegateWriteClosedForStreamTask() { 21 | let streamTaskInterceptor = MockStreamTaskInterceptor() 22 | let delegate = SessionDelegate() 23 | delegate.streamTaskInterceptor = streamTaskInterceptor 24 | 25 | delegate.urlSession(.shared, writeClosedFor: mockUrlSessionStreamTask) 26 | 27 | #expect(streamTaskInterceptor.writeClosed) 28 | } 29 | 30 | @Test("test SessionDelegate BetterRouteDiscoveredForStreamTask") 31 | func testSessionDelegateBetterRouteDiscoveredForStreamTask() { 32 | let streamTaskInterceptor = MockStreamTaskInterceptor() 33 | let delegate = SessionDelegate() 34 | delegate.streamTaskInterceptor = streamTaskInterceptor 35 | 36 | delegate.urlSession(.shared, betterRouteDiscoveredFor: mockUrlSessionStreamTask) 37 | 38 | #expect(streamTaskInterceptor.betterRouteDiscovered) 39 | } 40 | 41 | @Test("test SessionDelegate StreamTaskDidBecomeStreams") 42 | func testSessionDelegateStreamTaskDidBecomeStreams() { 43 | let streamTaskInterceptor = MockStreamTaskInterceptor() 44 | let delegate = SessionDelegate() 45 | delegate.streamTaskInterceptor = streamTaskInterceptor 46 | 47 | let inputStream = InputStream(data: Data()) 48 | let outputStream = OutputStream(toMemory: ()) 49 | delegate.urlSession(.shared, streamTask: mockUrlSessionStreamTask, didBecome: inputStream, outputStream: outputStream) 50 | 51 | #expect(streamTaskInterceptor.didBecomeStreams) 52 | } 53 | } 54 | 55 | // MARK: mock classes 56 | 57 | private class MockStreamTaskInterceptor: StreamTaskInterceptor { 58 | var readClosed = false 59 | func urlSession(_ session: URLSession, readClosedFor streamTask: URLSessionStreamTask) { 60 | readClosed = true 61 | } 62 | 63 | var writeClosed = false 64 | func urlSession(_ session: URLSession, writeClosedFor streamTask: URLSessionStreamTask) { 65 | writeClosed = true 66 | } 67 | 68 | var betterRouteDiscovered = false 69 | func urlSession(_ session: URLSession, betterRouteDiscoveredFor streamTask: URLSessionStreamTask) { 70 | betterRouteDiscovered = true 71 | } 72 | 73 | var didBecomeStreams = false 74 | func urlSession(_ session: URLSession, streamTask: URLSessionStreamTask, didBecome inputStream: InputStream, outputStream: OutputStream) { 75 | didBecomeStreams = true 76 | } 77 | } 78 | 79 | // MARK: mock variables 80 | 81 | private var mockUrlSessionStreamTask: URLSessionStreamTask { 82 | URLSession.shared.streamTask(withHostName: "", port: 0) 83 | } 84 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/WebSocket/Helpers/DefaultWebSocketTaskInterceptorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test DefaultWebSocketTaskInterceptor") 6 | final class DefaultWebSocketTaskInterceptorTests { 7 | 8 | @Test("calls onEvent when didOpenWithProtocol is invoked") 9 | func testDidOpenCallsOnEvent() { 10 | let url = URL(string: "wss://example.com/socket")! 11 | let session = URLSession(configuration: .default) 12 | let task = session.webSocketTask(with: url) 13 | 14 | var received: WebSocketTaskEvent? = nil 15 | let interceptor = DefaultWebSocketTaskInterceptor { event in 16 | received = event 17 | } 18 | 19 | interceptor.urlSession(session, webSocketTask: task, didOpenWithProtocol: "chat") 20 | 21 | guard case let .didOpenWithProtocol(proto) = received else { 22 | #expect(Bool(false)) 23 | return 24 | } 25 | #expect(proto == "chat") 26 | } 27 | 28 | @Test("calls onEvent when didCloseWith is invoked with code and reason") 29 | func testDidCloseCallsOnEvent() { 30 | let url = URL(string: "wss://example.com/socket")! 31 | let session = URLSession(configuration: .default) 32 | let task = session.webSocketTask(with: url) 33 | 34 | var received: WebSocketTaskEvent? = nil 35 | let interceptor = DefaultWebSocketTaskInterceptor { event in 36 | received = event 37 | } 38 | 39 | let reason = "bye".data(using: .utf8) 40 | interceptor.urlSession(session, webSocketTask: task, didCloseWith: .normalClosure, reason: reason) 41 | 42 | guard case let .didClose(code, data) = received else { 43 | #expect(Bool(false)) 44 | return 45 | } 46 | #expect(code == .normalClosure) 47 | #expect(data == reason) 48 | } 49 | 50 | @Test("default init does not crash and onEvent is mutable") 51 | func testDefaultInitAndOnEventMutation() { 52 | let url = URL(string: "wss://example.com/socket")! 53 | let session = URLSession(configuration: .default) 54 | let task = session.webSocketTask(with: url) 55 | 56 | let interceptor = DefaultWebSocketTaskInterceptor() 57 | 58 | var called = false 59 | interceptor.onEvent = { event in 60 | called = true 61 | } 62 | 63 | interceptor.urlSession(session, webSocketTask: task, didOpenWithProtocol: nil) 64 | #expect(called == true) 65 | } 66 | 67 | @Test("calls onEvent when task:didCompleteWithError is invoked") 68 | func testDidCompleteWithErrorCallsOnEvent() { 69 | let url = URL(string: "wss://example.com/socket")! 70 | let session = URLSession(configuration: .default) 71 | let task = session.webSocketTask(with: url) 72 | 73 | var received: WebSocketTaskEvent? = nil 74 | let interceptor = DefaultWebSocketTaskInterceptor { event in 75 | received = event 76 | } 77 | 78 | let error = URLError(.timedOut) 79 | interceptor.urlSession(session, task: task, didCompleteWithError: error) 80 | 81 | guard case let .didOpenWithError(err) = received else { 82 | #expect(Bool(false)) 83 | return 84 | } 85 | let receivedNSError = err as NSError 86 | let expectedNSError = error as NSError 87 | #expect(receivedNSError.domain == expectedNSError.domain) 88 | #expect(receivedNSError.code == expectedNSError.code) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Headers/MimeType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum MimeType: Equatable { 4 | // MARK: - Application Types 5 | case json 6 | case xml 7 | case formUrlEncoded 8 | case multipartFormData(boundary: String) 9 | case pdf 10 | case zip 11 | case octetStream 12 | case javascript 13 | case wasm 14 | 15 | // MARK: - Text Types 16 | case plain 17 | case html 18 | case css 19 | case csv 20 | case rtf 21 | case xmlText 22 | 23 | // MARK: - Image Types 24 | case jpeg 25 | case png 26 | case gif 27 | case webp 28 | case svg 29 | case bmp 30 | case ico 31 | case tiff 32 | 33 | // MARK: - Video Types 34 | case mp4 35 | case avi 36 | case mov 37 | case wmv 38 | case flv 39 | case webm 40 | case mkv 41 | case quicktime 42 | 43 | // MARK: - Audio Types 44 | case mp3 45 | case wav 46 | case ogg 47 | case aac 48 | case flac 49 | case m4a 50 | case wma 51 | 52 | // MARK: - Font Types 53 | case ttf 54 | case otf 55 | case woff 56 | case woff2 57 | case eot 58 | 59 | // MARK: - Custom 60 | case custom(String) 61 | 62 | var value: String { 63 | switch self { 64 | // Application Types 65 | case .json: return "application/json" 66 | case .xml: return "application/xml" 67 | case .formUrlEncoded: return "application/x-www-form-urlencoded" 68 | case .multipartFormData(let boundary): return "multipart/form-data; boundary=\(boundary)" 69 | case .pdf: return "application/pdf" 70 | case .zip: return "application/zip" 71 | case .octetStream: return "application/octet-stream" 72 | case .javascript: return "application/javascript" 73 | case .wasm: return "application/wasm" 74 | 75 | // Text Types 76 | case .plain: return "text/plain" 77 | case .html: return "text/html" 78 | case .css: return "text/css" 79 | case .csv: return "text/csv" 80 | case .rtf: return "text/rtf" 81 | case .xmlText: return "text/xml" 82 | 83 | // Image Types 84 | case .jpeg: return "image/jpeg" 85 | case .png: return "image/png" 86 | case .gif: return "image/gif" 87 | case .webp: return "image/webp" 88 | case .svg: return "image/svg+xml" 89 | case .bmp: return "image/bmp" 90 | case .ico: return "image/x-icon" 91 | case .tiff: return "image/tiff" 92 | 93 | // Video Types 94 | case .mp4: return "video/mp4" 95 | case .avi: return "video/x-msvideo" 96 | case .mov: return "video/quicktime" 97 | case .wmv: return "video/x-ms-wmv" 98 | case .flv: return "video/x-flv" 99 | case .webm: return "video/webm" 100 | case .mkv: return "video/x-matroska" 101 | case .quicktime: return "video/quicktime" 102 | 103 | // Audio Types 104 | case .mp3: return "audio/mpeg" 105 | case .wav: return "audio/wav" 106 | case .ogg: return "audio/ogg" 107 | case .aac: return "audio/aac" 108 | case .flac: return "audio/flac" 109 | case .m4a: return "audio/mp4" 110 | case .wma: return "audio/x-ms-wma" 111 | 112 | // Font Types 113 | case .ttf: return "font/ttf" 114 | case .otf: return "font/otf" 115 | case .woff: return "font/woff" 116 | case .woff2: return "font/woff2" 117 | case .eot: return "application/vnd.ms-fontobject" 118 | 119 | // Custom 120 | case .custom(let value): return value 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Interceptors/SessoionDelegate+Extensions+Tests/SessionDelegate+URLSessionWebSocketDelegate+Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test SessionDelegateURLSessionWebSocketDelegate") 6 | final class SessionDelegateURLSessionWebSocketDelegateTests { 7 | 8 | @Test("test SessionDelegate WebSocket DidOpenWithProtocol") 9 | func testSessionDelegateWebSocketDidOpenWithProtocol() { 10 | let webSocketInterceptor = SpyWebSocketTaskInterceptor() 11 | let delegate = SessionDelegate() 12 | delegate.webSocketTaskInterceptor = webSocketInterceptor 13 | 14 | let webSocketTask = mockURLSessionWebSocketTask 15 | let protocolString = "test-protocol" 16 | delegate.urlSession(.shared, webSocketTask: webSocketTask, didOpenWithProtocol: protocolString) 17 | 18 | #expect(webSocketInterceptor.didOpenWithProtocol) 19 | #expect(webSocketInterceptor.receivedProtocol == protocolString) 20 | } 21 | 22 | @Test("test SessionDelegate WebSocket DidCloseWithCodeAndReason") 23 | func testSessionDelegateWebSocketDidCloseWithCodeAndReason() { 24 | let webSocketInterceptor = SpyWebSocketTaskInterceptor() 25 | let delegate = SessionDelegate() 26 | delegate.webSocketTaskInterceptor = webSocketInterceptor 27 | 28 | let closeCode: URLSessionWebSocketTask.CloseCode = .goingAway 29 | let reasonData = "Closed by server".data(using: .utf8) 30 | delegate.urlSession(.shared, webSocketTask: mockURLSessionWebSocketTask, didCloseWith: closeCode, reason: reasonData) 31 | 32 | #expect(webSocketInterceptor.didCloseWithCodeAndReason) 33 | #expect(webSocketInterceptor.receivedCloseCode == closeCode) 34 | #expect(webSocketInterceptor.receivedReason == reasonData) 35 | } 36 | 37 | @Test("test SessionDelegate WebSocket didCompleteWithError") 38 | func testSessionDelegateWebSocketDidCompleteWithError() { 39 | let webSocketInterceptor = SpyWebSocketTaskInterceptor() 40 | let delegate = SessionDelegate() 41 | delegate.webSocketTaskInterceptor = webSocketInterceptor 42 | 43 | 44 | let error = NSError(domain: "test", code: 0) 45 | delegate.urlSession(.shared, task: .init(), didCompleteWithError: error) 46 | 47 | #expect(webSocketInterceptor.didCompleteWithError) 48 | #expect(webSocketInterceptor.receivedError as? NSError == error) 49 | } 50 | } 51 | 52 | // MARK: mock class 53 | 54 | private class SpyWebSocketTaskInterceptor: WebSocketTaskInterceptor { 55 | var onEvent: ((WebSocketTaskEvent) -> Void)? = { _ in } 56 | 57 | var didOpenWithProtocol = false 58 | var receivedProtocol: String? 59 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { 60 | didOpenWithProtocol = true 61 | receivedProtocol = `protocol` 62 | } 63 | 64 | var didCompleteWithError = false 65 | var receivedError: Error? 66 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: any Error) { 67 | didCompleteWithError = true 68 | receivedError = error 69 | } 70 | 71 | var didCloseWithCodeAndReason = false 72 | var receivedCloseCode: URLSessionWebSocketTask.CloseCode? 73 | var receivedReason: Data? 74 | func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { 75 | didCloseWithCodeAndReason = true 76 | receivedCloseCode = closeCode 77 | receivedReason = reason 78 | } 79 | } 80 | 81 | // MARK: mock variables 82 | 83 | private var mockURLSessionWebSocketTask: URLSessionWebSocketTask { 84 | URLSession.shared.webSocketTask(with: URL(string: "wss://www.example.com")!) 85 | } 86 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Request/RequestTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test Request") 6 | final class RequestTests { 7 | 8 | @Test("test Request .httpMethod") 9 | func testRequestHttpMethod() { 10 | #expect(MockRequest().httpMethod == .GET) 11 | } 12 | 13 | @Test("test Request .baseUrlString") 14 | func testRequestBaseUrlString() { 15 | #expect(MockRequest().baseUrlString == "https://www.example.com") 16 | } 17 | 18 | @Test("test Request .parameters") 19 | func testRequestParameters() throws { 20 | let sut = MockRequest() 21 | let parameters = try #require(sut.parameters) 22 | #expect(parameters.count == 3) 23 | 24 | let firstParam = try #require(parameters[0]) 25 | #expect(firstParam.key == "key_1") 26 | #expect(firstParam.key == "key_1") 27 | 28 | let secondParam = try #require(parameters[1]) 29 | #expect(secondParam.key == "key_2") 30 | #expect(secondParam.key == "key_2") 31 | 32 | let thirdParam = try #require(parameters[2]) 33 | #expect(thirdParam.key == "key_3") 34 | #expect(thirdParam.key == "key_3") 35 | } 36 | 37 | @Test("test Request .headers") 38 | func testRequestHeaders() throws { 39 | let sut = MockRequest() 40 | let headers = try #require(sut.headers) 41 | #expect(headers.count == 3) 42 | 43 | let firstHeader = try #require(headers[0]) 44 | #expect(firstHeader.key == "Accept") 45 | #expect(firstHeader.value == "application/json") 46 | 47 | let secondHeader = try #require(headers[1]) 48 | #expect(secondHeader.key == "Content-Type") 49 | #expect(secondHeader.value == "application/json") 50 | 51 | let thirdHeader = try #require(headers[2]) 52 | #expect(thirdHeader.key == "Authorization") 53 | #expect(thirdHeader.value == "Bearer api_key") 54 | } 55 | 56 | @Test("test Request .timeoutInterval") 57 | func testRequestTimeoutInterval() { 58 | #expect(MockRequest().timeoutInterval == 60) 59 | } 60 | 61 | @Test("test Request .urlRequest") 62 | func testRequestBuildMethod() throws { 63 | let request = MockRequest() 64 | let sut = try #require(request.urlRequest) 65 | 66 | #expect(sut.url?.absoluteString == "https://www.example.com?key_1=value_1&key_2=value_2&key_3=value_3") 67 | #expect(sut.httpMethod == "GET") 68 | #expect(sut.httpBody == "{\"name\": \"John\"}".data(using: .utf8)) 69 | #expect(sut.timeoutInterval == 60) 70 | #expect(sut.value(forHTTPHeaderField: "Content-Type") == "application/json") 71 | #expect(sut.value(forHTTPHeaderField: "Authorization") == "Bearer api_key") 72 | } 73 | 74 | @Test("test Request .cachePolicy") 75 | func testRequestCachePolicy() throws { 76 | let request = MockRequest(cachePolicy: .returnCacheDataElseLoad) 77 | let sut = try #require(request.urlRequest) 78 | #expect(sut.cachePolicy == .returnCacheDataElseLoad) 79 | } 80 | 81 | } 82 | 83 | private struct MockRequest: Request { 84 | var httpMethod: HTTPMethod { .GET } 85 | 86 | var baseUrlString: String { "https://www.example.com" } 87 | 88 | var parameters: [HTTPParameter]? { 89 | [ 90 | .init(key: "key_1", value: "value_1"), 91 | .init(key: "key_2", value: "value_2"), 92 | .init(key: "key_3", value: "value_3") 93 | ] 94 | } 95 | 96 | var headers: [HTTPHeader]? { 97 | [ 98 | .accept(.json), 99 | .contentType(.json), 100 | .authorization(.bearer("api_key")) 101 | ] 102 | } 103 | 104 | var body: EZNetworking.HTTPBody? { 105 | Data(jsonString: "{\"name\": \"John\"}") 106 | } 107 | 108 | var cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy 109 | } 110 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Performers/RequestPerformer.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public struct RequestPerformer: RequestPerformable { 5 | private let urlSession: URLSessionTaskProtocol 6 | private let validator: ResponseValidator 7 | private let requestDecoder: RequestDecodable 8 | 9 | public init( 10 | urlSession: URLSessionTaskProtocol = URLSession.shared, 11 | validator: ResponseValidator = ResponseValidatorImpl(), 12 | requestDecoder: RequestDecodable = RequestDecoder(), 13 | sessionDelegate: SessionDelegate? = nil 14 | ) { 15 | if let urlSession = urlSession as? URLSession { 16 | // If the session already has a delegate, use it (if it's a SessionDelegate) 17 | if let _ = urlSession.delegate as? SessionDelegate { 18 | self.urlSession = urlSession 19 | } else { 20 | // If no delegate or not a SessionDelegate, create one 21 | let newDelegate = sessionDelegate ?? SessionDelegate() 22 | let newSession = URLSession( 23 | configuration: urlSession.configuration, 24 | delegate: newDelegate, 25 | delegateQueue: urlSession.delegateQueue 26 | ) 27 | self.urlSession = newSession 28 | } 29 | } else { 30 | // For mocks or custom protocol types 31 | self.urlSession = urlSession 32 | } 33 | self.validator = validator 34 | self.requestDecoder = requestDecoder 35 | } 36 | 37 | // MARK: Async Await 38 | public func perform(request: Request, decodeTo decodableObject: T.Type) async throws -> T { 39 | try await withCheckedThrowingContinuation { continuation in 40 | performDataTask(request: request, decodeTo: decodableObject, completion: { result in 41 | switch result { 42 | case .success(let success): 43 | continuation.resume(returning: success) 44 | case .failure(let failure): 45 | continuation.resume(throwing: failure) 46 | } 47 | }) 48 | } 49 | } 50 | 51 | // MARK: Completion Handler 52 | @discardableResult 53 | public func performTask(request: Request, decodeTo decodableObject: T.Type, completion: @escaping ((Result) -> Void)) -> URLSessionDataTask? { 54 | return performDataTask(request: request, decodeTo: decodableObject, completion: completion) 55 | } 56 | 57 | // MARK: Publisher 58 | public func performPublisher(request: Request, decodeTo decodableObject: T.Type) -> AnyPublisher { 59 | Future { promise in 60 | performDataTask(request: request, decodeTo: decodableObject) { result in 61 | promise(result) 62 | } 63 | } 64 | .eraseToAnyPublisher() 65 | } 66 | 67 | // MARK: Core 68 | 69 | @discardableResult 70 | private func performDataTask(request: Request, decodeTo decodableObject: T.Type, completion: @escaping ((Result) -> Void)) -> URLSessionDataTask? { 71 | 72 | guard let urlRequest = request.urlRequest else { 73 | completion(.failure(.internalError(.noRequest))) 74 | return nil 75 | } 76 | let task = urlSession.dataTask(with: urlRequest) { data, urlResponse, error in 77 | do { 78 | try validator.validateNoError(error) 79 | try validator.validateStatus(from: urlResponse) 80 | let validData = try validator.validateData(data) 81 | 82 | let result = try requestDecoder.decode(decodableObject, from: validData) 83 | completion(.success(result)) 84 | } catch { 85 | completion(.failure(mapError(error))) 86 | } 87 | } 88 | task.resume() 89 | return task 90 | } 91 | 92 | private func mapError(_ error: Error) -> NetworkingError { 93 | if let networkError = error as? NetworkingError { return networkError } 94 | if let urlError = error as? URLError { return .urlError(urlError) } 95 | return .internalError(.unknown) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Sources/EZNetworking/HTTP/Headers/AuthorizationType.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum AuthorizationType: Equatable { 4 | // MARK: - Standard Authorization Types 5 | 6 | /// Bearer token authentication (OAuth 2.0, JWT) 7 | case bearer(String) 8 | 9 | /// Basic authentication (username:password base64 encoded) 10 | case basic(String) 11 | 12 | /// Digest authentication 13 | case digest(String) 14 | 15 | /// API Key authentication 16 | case apiKey(String) 17 | 18 | /// OAuth 1.0 authentication 19 | case oauth1(String) 20 | 21 | /// OAuth 2.0 with different token types 22 | case oauth2(String, tokenType: String = "Bearer") 23 | 24 | /// AWS Signature Version 4 25 | case aws4(String) 26 | 27 | /// Hawk authentication 28 | case hawk(String) 29 | 30 | /// Custom authorization header value 31 | case custom(String) 32 | 33 | // MARK: - Computed Properties 34 | 35 | var value: String { 36 | switch self { 37 | // Standard Authorization Types 38 | case .bearer(let token): 39 | return "Bearer \(token)" 40 | 41 | case .basic(let credentials): 42 | return "Basic \(credentials)" 43 | 44 | case .digest(let credentials): 45 | return "Digest \(credentials)" 46 | 47 | case .apiKey(let key): 48 | return "ApiKey \(key)" 49 | 50 | case .oauth1(let credentials): 51 | return "OAuth \(credentials)" 52 | 53 | case .oauth2(let token, let tokenType): 54 | return "\(tokenType) \(token)" 55 | 56 | case .aws4(let signature): 57 | return "AWS4-HMAC-SHA256 \(signature)" 58 | 59 | case .hawk(let credentials): 60 | return "Hawk \(credentials)" 61 | 62 | case .custom(let value): 63 | return value 64 | } 65 | } 66 | 67 | // MARK: - Convenience Initializers 68 | 69 | /// Creates a Basic authorization with username and password 70 | public static func basic(username: String, password: String) -> AuthorizationType { 71 | let credentials = "\(username):\(password)" 72 | let base64Credentials = Data(credentials.utf8).base64EncodedString() 73 | return .basic(base64Credentials) 74 | } 75 | 76 | /// Creates an API Key authorization with a specific header name 77 | public static func apiKeyWithHeader(_ key: String, headerName: String = "X-API-Key") -> AuthorizationType { 78 | return .custom("\(headerName) \(key)") 79 | } 80 | 81 | /// Creates a custom authorization with a specific scheme 82 | public static func custom(scheme: String, credentials: String) -> AuthorizationType { 83 | return .custom("\(scheme) \(credentials)") 84 | } 85 | } 86 | 87 | // MARK: - AuthorizationType Extensions 88 | 89 | extension AuthorizationType { 90 | /// Returns the authorization scheme (e.g., "Bearer", "Basic", "Digest") 91 | public var scheme: String { 92 | switch self { 93 | case .bearer: 94 | return "Bearer" 95 | case .basic: 96 | return "Basic" 97 | case .digest: 98 | return "Digest" 99 | case .apiKey: 100 | return "ApiKey" 101 | case .oauth1: 102 | return "OAuth" 103 | case .oauth2(_, let tokenType): 104 | return tokenType 105 | case .aws4: 106 | return "AWS4-HMAC-SHA256" 107 | case .hawk: 108 | return "Hawk" 109 | case .custom(let value): 110 | // Extract scheme from custom value (everything before first space) 111 | if let spaceIndex = value.firstIndex(of: " ") { 112 | return String(value[.. CachedURLResponse? { 81 | didCallWillCacheResponse = true 82 | return proposedResponse 83 | } 84 | } 85 | 86 | private class MockDataTaskInterceptor: DataTaskInterceptor { 87 | var didRecieveData = false 88 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { 89 | didRecieveData = true 90 | } 91 | 92 | var didBecomeDownloadTask = false 93 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome downloadTask: URLSessionDownloadTask) { 94 | didBecomeDownloadTask = true 95 | } 96 | 97 | var didBecomeStreamTask = false 98 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didBecome streamTask: URLSessionStreamTask) { 99 | didBecomeStreamTask = true 100 | } 101 | 102 | var didReceiveResponse = false 103 | func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse) async -> URLSession.ResponseDisposition { 104 | didReceiveResponse = true 105 | return .allow 106 | } 107 | } 108 | 109 | // MARK: mock variables 110 | 111 | private var mockUrlSessionDataTask: URLSessionDataTask { 112 | URLSession.shared.dataTask(with: URLRequest(url: URL(string: "https://www.example.com")!)) 113 | } 114 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Validator/ResponseValidatorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test URLResponseValidator") 6 | final class URLResponseValidatorTests { 7 | 8 | let sut = ResponseValidatorImpl() 9 | 10 | private struct SomeUnknownError: Error {} 11 | 12 | // MARK: - test validateNoError() 13 | 14 | @Test("test validateNoError givenNilError NoThrow") 15 | func test_validateNoError_givenNilError_NoThrow() throws { 16 | #expect(throws: Never.self) { try sut.validateNoError(nil) } 17 | } 18 | 19 | @Test("test validateNoError givenURLError Throws") 20 | func test_validateNoError_givenURLError_Throws() throws { 21 | #expect(throws: NetworkingError.urlError(URLError(.notConnectedToInternet)).self) { 22 | try sut.validateNoError(URLError(.notConnectedToInternet)) 23 | } 24 | } 25 | 26 | @Test("test validateNoError givenClientError Throws") 27 | func test_validateNoError_givenClientError_Throws() throws { 28 | #expect(throws: NetworkingError.internalError(.requestFailed(SomeUnknownError())).self) { 29 | try sut.validateNoError(SomeUnknownError()) 30 | } 31 | } 32 | 33 | // MARK: - test validateData() 34 | 35 | @Test("test validateData givenData NoThrow") 36 | func test_validateData_givenData_NoThrow() throws { 37 | #expect(throws: Never.self) { try sut.validateData(MockData.mockPersonJsonData) } 38 | } 39 | 40 | @Test("test validateData givenNilData Throws") 41 | func test_validateData_givenNilData_Throws() throws { 42 | #expect(throws: NetworkingError.internalError(.noData).self) { 43 | try sut.validateData(nil) 44 | } 45 | } 46 | 47 | // MARK: - test validateUrl() 48 | 49 | @Test("test validateUrl givenData NoThrow") 50 | func test_validateUrl_givenData_NoThrow() throws { 51 | #expect(throws: Never.self) { try sut.validateUrl(URL(string: "https://www.example.com")!) } 52 | } 53 | 54 | @Test("test validateUrl givenNilData Throws") 55 | func test_validateUrl_givenNilData_Throws() throws { 56 | #expect(throws: NetworkingError.internalError(.noURL).self) { 57 | try sut.validateUrl(nil) 58 | } 59 | } 60 | 61 | // MARK: - test validateStatus() 62 | 63 | @Test("test validateStatus givenNilResponse Throws") 64 | func test_validateStatus_givenNilResponse_Throws() throws { 65 | #expect(throws: NetworkingError.internalError(.noResponse).self) { 66 | try sut.validateStatus(from: nil) 67 | } 68 | } 69 | 70 | @Test("test validateStatus givenURLResponse Throws") 71 | func test_validateStatus_givenURLResponse_Throws() throws { 72 | #expect(throws: NetworkingError.internalError(.noHTTPURLResponse).self) { 73 | try sut.validateStatus(from: URLResponse()) 74 | } 75 | } 76 | 77 | // MARK: 1xx status code 78 | 79 | @Test("test validateStatus givenHTTPURLResponseStatusCode100 Throws") 80 | func test_validateStatus_givenHTTPURLResponseStatusCode100_Throws() throws { 81 | #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 100)).self) { 82 | try sut.validateStatus(from: createHttpUrlResponse(statusCode: 100)) 83 | } 84 | } 85 | 86 | // MARK: 2xx status code 87 | 88 | @Test("test validateStatus givenHTTPURLResponseStatusCode200 NoThrow") 89 | func test_validateStatus_givenHTTPURLResponseStatusCode200_NoThrow() throws { 90 | #expect(throws: Never.self) { try sut.validateStatus(from: createHttpUrlResponse(statusCode: 200)) } 91 | } 92 | 93 | // MARK: 3xx status code 94 | 95 | @Test("test validateStatus givenHTTPURLResponseStatusCode300 Throws") 96 | func test_validateStatus_givenHTTPURLResponseStatusCode300_Throws() throws { 97 | #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 300)).self) { 98 | try sut.validateStatus(from: createHttpUrlResponse(statusCode: 300)) 99 | } 100 | } 101 | 102 | // MARK: 4xx status code 103 | 104 | @Test("test validateStatus givenHTTPURLResponseStatusCode400 Throws") 105 | func test_validateStatus_givenHTTPURLResponseStatusCode400_Throws() throws { 106 | #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 400)).self) { 107 | try sut.validateStatus(from: createHttpUrlResponse(statusCode: 400)) 108 | } 109 | } 110 | 111 | // MARK: 5xx status code 112 | 113 | @Test("test validateStatus givenHTTPURLResponseStatusCode500 Throws") 114 | func test_validateStatus_givenHTTPURLResponseStatusCode500_Throws() throws { 115 | #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 500)).self) { 116 | try sut.validateStatus(from: createHttpUrlResponse(statusCode: 500)) 117 | } 118 | } 119 | } 120 | 121 | // MARK: - Test Helpers 122 | 123 | extension URLResponseValidatorTests { 124 | func createHttpUrlResponse(statusCode: Int) -> HTTPURLResponse { 125 | return HTTPURLResponse(url: url, statusCode: statusCode, httpVersion: nil, headerFields: nil)! 126 | } 127 | 128 | var url: URL { 129 | return URL(string: "https://example.com")! 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/DataUploader/DataUploader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public class DataUploader: DataUploadable { 5 | private let urlSession: URLSessionTaskProtocol 6 | private let validator: ResponseValidator 7 | private var sessionDelegate: SessionDelegate 8 | 9 | private let fallbackUploadTaskInterceptor: DefaultUploadTaskInterceptor = DefaultUploadTaskInterceptor() 10 | 11 | // MARK: init 12 | 13 | public init( 14 | urlSession: URLSessionTaskProtocol = URLSession.shared, 15 | validator: ResponseValidator = ResponseValidatorImpl(), 16 | sessionDelegate: SessionDelegate? = nil 17 | ) { 18 | if let urlSession = urlSession as? URLSession { 19 | if let existingDelegate = urlSession.delegate as? SessionDelegate { 20 | self.sessionDelegate = existingDelegate 21 | self.urlSession = urlSession 22 | } else { 23 | let newDelegate = sessionDelegate ?? SessionDelegate() 24 | let newSession = URLSession( 25 | configuration: urlSession.configuration, 26 | delegate: newDelegate, 27 | delegateQueue: urlSession.delegateQueue 28 | ) 29 | self.sessionDelegate = newDelegate 30 | self.urlSession = newSession 31 | } 32 | } else { 33 | self.sessionDelegate = sessionDelegate ?? SessionDelegate() 34 | self.urlSession = urlSession 35 | } 36 | self.validator = validator 37 | } 38 | 39 | // MARK: Async Await 40 | 41 | public func uploadData(_ data: Data, with request: Request, progress: UploadProgressHandler?) async throws -> Data { 42 | try await withCheckedThrowingContinuation { continuation in 43 | self._uploadDataTask(data, with: request, progress: progress) { result in 44 | switch result { 45 | case .success(let data): 46 | continuation.resume(returning: data) 47 | case .failure(let error): 48 | continuation.resume(throwing: error) 49 | } 50 | } 51 | } 52 | } 53 | 54 | // MARK: Completion Handler 55 | 56 | @discardableResult 57 | public func uploadDataTask(_ data: Data, with request: Request, progress: UploadProgressHandler?, completion: @escaping (UploadCompletionHandler)) -> URLSessionUploadTask? { 58 | return _uploadDataTask(data, with: request, progress: progress, completion: completion) 59 | } 60 | 61 | // MARK: Publisher 62 | 63 | public func uploadDataPublisher(_ data: Data, with request: Request, progress: UploadProgressHandler?) -> AnyPublisher { 64 | Future { promise in 65 | _ = self._uploadDataTask(data, with: request, progress: progress) { result in 66 | promise(result) 67 | } 68 | } 69 | .eraseToAnyPublisher() 70 | } 71 | 72 | // MARK: Async Stream 73 | 74 | public func uploadDataStream(_ data: Data, with request: Request) -> AsyncStream { 75 | AsyncStream { continuation in 76 | let progressHandler: UploadProgressHandler = { progress in 77 | continuation.yield(.progress(progress)) 78 | } 79 | let task = self._uploadDataTask(data, with: request, progress: progressHandler) { result in 80 | switch result { 81 | case .success(let data): 82 | continuation.yield(.success(data)) 83 | case .failure(let error): 84 | continuation.yield(.failure(error)) 85 | } 86 | continuation.finish() 87 | } 88 | continuation.onTermination = { @Sendable _ in 89 | task?.cancel() 90 | } 91 | } 92 | } 93 | 94 | // MARK: Core 95 | 96 | @discardableResult 97 | private func _uploadDataTask(_ data: Data, with request: Request, progress: UploadProgressHandler?, completion: @escaping (UploadCompletionHandler)) -> URLSessionUploadTask? { 98 | let request = request 99 | configureProgressTracking(progress: progress) 100 | 101 | guard let urlRequest = request.urlRequest else { 102 | completion(.failure(.internalError(.noRequest))) 103 | return nil 104 | } 105 | 106 | let task = urlSession.uploadTask(with: urlRequest, from: data) { [weak self] data, response, error in 107 | guard let self else { 108 | completion(.failure(.internalError(.lostReferenceOfSelf))) 109 | return 110 | } 111 | do { 112 | try self.validator.validateNoError(error) 113 | try self.validator.validateStatus(from: response) 114 | let validData = try self.validator.validateData(data) 115 | completion(.success(validData)) 116 | } catch { 117 | completion(.failure(self.mapError(error))) 118 | } 119 | } 120 | task.resume() 121 | return task 122 | } 123 | 124 | private func mapError(_ error: Error) -> NetworkingError { 125 | if let networkError = error as? NetworkingError { return networkError } 126 | if let urlError = error as? URLError { return .urlError(urlError) } 127 | return .internalError(.unknown) 128 | } 129 | 130 | private func configureProgressTracking(progress: UploadProgressHandler?) { 131 | guard let progress else { return } 132 | if sessionDelegate.uploadTaskInterceptor != nil { 133 | sessionDelegate.uploadTaskInterceptor?.progress = progress 134 | } else { 135 | fallbackUploadTaskInterceptor.progress = progress 136 | sessionDelegate.uploadTaskInterceptor = fallbackUploadTaskInterceptor 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Uploader/FileUploader/FileUploader.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | public class FileUploader: FileUploadable { 5 | private let urlSession: URLSessionTaskProtocol 6 | private let validator: ResponseValidator 7 | private var sessionDelegate: SessionDelegate 8 | 9 | private let fallbackUploadTaskInterceptor: DefaultUploadTaskInterceptor = DefaultUploadTaskInterceptor() 10 | 11 | // MARK: init 12 | 13 | public init( 14 | urlSession: URLSessionTaskProtocol = URLSession.shared, 15 | validator: ResponseValidator = ResponseValidatorImpl(), 16 | sessionDelegate: SessionDelegate? = nil 17 | ) { 18 | if let urlSession = urlSession as? URLSession { 19 | if let existingDelegate = urlSession.delegate as? SessionDelegate { 20 | self.sessionDelegate = existingDelegate 21 | self.urlSession = urlSession 22 | } else { 23 | let newDelegate = sessionDelegate ?? SessionDelegate() 24 | let newSession = URLSession( 25 | configuration: urlSession.configuration, 26 | delegate: newDelegate, 27 | delegateQueue: urlSession.delegateQueue 28 | ) 29 | self.sessionDelegate = newDelegate 30 | self.urlSession = newSession 31 | } 32 | } else { 33 | self.sessionDelegate = sessionDelegate ?? SessionDelegate() 34 | self.urlSession = urlSession 35 | } 36 | self.validator = validator 37 | } 38 | 39 | // MARK: Async Await 40 | 41 | public func uploadFile(_ fileURL: URL, with request: any Request, progress: UploadProgressHandler?) async throws -> Data { 42 | try await withCheckedThrowingContinuation { continuation in 43 | self._uploadFileTask(fileURL, with: request, progress: progress) { result in 44 | switch result { 45 | case .success(let data): 46 | continuation.resume(returning: data) 47 | case .failure(let error): 48 | continuation.resume(throwing: error) 49 | } 50 | } 51 | } 52 | 53 | } 54 | 55 | // MARK: Completion Handler 56 | 57 | @discardableResult 58 | public func uploadFileTask(_ fileURL: URL, with request: any Request, progress: UploadProgressHandler?, completion: @escaping UploadCompletionHandler) -> URLSessionUploadTask? { 59 | return _uploadFileTask(fileURL, with: request, progress: progress, completion: completion) 60 | } 61 | 62 | // MARK: Publisher 63 | 64 | public func uploadFilePublisher(_ fileURL: URL, with request: any Request, progress: UploadProgressHandler?) -> AnyPublisher { 65 | Future { promise in 66 | _ = self._uploadFileTask(fileURL, with: request, progress: progress) { result in 67 | promise(result) 68 | } 69 | } 70 | .eraseToAnyPublisher() 71 | } 72 | 73 | // MARK: AsyncStream 74 | 75 | public func uploadFileStream(_ fileURL: URL, with request: any Request) -> AsyncStream { 76 | AsyncStream { continuation in 77 | let progressHandler: UploadProgressHandler = { progress in 78 | continuation.yield(.progress(progress)) 79 | } 80 | let task = self._uploadFileTask(fileURL, with: request, progress: progressHandler) { result in 81 | switch result { 82 | case .success(let data): 83 | continuation.yield(.success(data)) 84 | case .failure(let error): 85 | continuation.yield(.failure(error)) 86 | } 87 | continuation.finish() 88 | } 89 | continuation.onTermination = { @Sendable _ in 90 | task?.cancel() 91 | } 92 | } 93 | } 94 | 95 | // MARK: - Core 96 | 97 | private func _uploadFileTask(_ fileURL: URL, with request: Request, progress: UploadProgressHandler?, completion: @escaping (UploadCompletionHandler)) -> URLSessionUploadTask? { 98 | let request = request 99 | configureProgressTracking(progress: progress) 100 | 101 | guard let urlRequest = request.urlRequest else { 102 | completion(.failure(.internalError(.noRequest))) 103 | return nil 104 | } 105 | 106 | let task = urlSession.uploadTask(with: urlRequest, fromFile: fileURL) { [weak self] data, response, error in 107 | guard let self else { 108 | completion(.failure(.internalError(.lostReferenceOfSelf))) 109 | return 110 | } 111 | do { 112 | try self.validator.validateNoError(error) 113 | try self.validator.validateStatus(from: response) 114 | let validData = try self.validator.validateData(data) 115 | completion(.success(validData)) 116 | } catch { 117 | completion(.failure(self.mapError(error))) 118 | } 119 | } 120 | task.resume() 121 | return task 122 | } 123 | 124 | private func mapError(_ error: Error) -> NetworkingError { 125 | if let networkError = error as? NetworkingError { return networkError } 126 | if let urlError = error as? URLError { return .urlError(urlError) } 127 | return .internalError(.unknown) 128 | } 129 | 130 | private func configureProgressTracking(progress: UploadProgressHandler?) { 131 | guard let progress else { return } 132 | if sessionDelegate.uploadTaskInterceptor != nil { 133 | sessionDelegate.uploadTaskInterceptor?.progress = progress 134 | } else { 135 | fallbackUploadTaskInterceptor.progress = progress 136 | sessionDelegate.uploadTaskInterceptor = fallbackUploadTaskInterceptor 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Error/WebSocketError/WebSocketError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum WebSocketError: Error { 4 | // Connection errors 5 | case notConnected 6 | case stillConnecting 7 | case alreadyConnected 8 | case connectionFailed(underlying: Error) 9 | case connectionTimeout 10 | case invalidURL 11 | case unsupportedProtocol(String) 12 | 13 | // Communication errors 14 | case sendFailed(underlying: Error) 15 | case receiveFailed(underlying: Error) 16 | case invalidMessageFormat 17 | case messageEncodingFailed 18 | case messageDecodingFailed 19 | 20 | // Ping/pong errors 21 | case pingFailed(underlying: Error) 22 | case pongTimeout 23 | case keepAliveFailure(consecutiveFailures: Int) 24 | 25 | // Disconnection errors 26 | case unexpectedDisconnection(code: URLSessionWebSocketTask.CloseCode, reason: String?) 27 | case forcedDisconnection 28 | 29 | // Task errors 30 | case taskNotInitialized 31 | case taskCancelled 32 | 33 | // Stream errors 34 | case streamAlreadyCreated 35 | case streamNotAvailable 36 | } 37 | 38 | // MARK: - LocalizedError conformance for better error messages 39 | 40 | extension WebSocketError: LocalizedError { 41 | public var errorDescription: String? { 42 | switch self { 43 | case .notConnected: 44 | return "WebSocket is not connected" 45 | case .stillConnecting: 46 | return "WebSocket is still connecting" 47 | case .alreadyConnected: 48 | return "WebSocket is already connected" 49 | case .connectionFailed(let error): 50 | return "WebSocket connection failed: \(error.localizedDescription)" 51 | case .connectionTimeout: 52 | return "WebSocket connection timed out" 53 | case .invalidURL: 54 | return "Invalid WebSocket URL" 55 | case .unsupportedProtocol(let protocolString): 56 | return "Unsupported WebSocket protocol: \(protocolString)" 57 | 58 | case .sendFailed(let error): 59 | return "Failed to send WebSocket message: \(error.localizedDescription)" 60 | case .receiveFailed(let error): 61 | return "Failed to receive WebSocket message: \(error.localizedDescription)" 62 | case .invalidMessageFormat: 63 | return "Invalid WebSocket message format" 64 | case .messageEncodingFailed: 65 | return "Failed to encode message for WebSocket" 66 | case .messageDecodingFailed: 67 | return "Failed to decode WebSocket message" 68 | 69 | case .pingFailed(let error): 70 | return "WebSocket ping failed: \(error.localizedDescription)" 71 | case .pongTimeout: 72 | return "WebSocket pong response timed out" 73 | case .keepAliveFailure(let count): 74 | return "WebSocket keep-alive failed after \(count) consecutive attempts" 75 | 76 | case .unexpectedDisconnection(let code, let reason): 77 | let reasonText = reason ?? "No reason provided" 78 | return "WebSocket disconnected unexpectedly with code \(code.rawValue): \(reasonText)" 79 | case .forcedDisconnection: 80 | return "WebSocket was forcefully disconnected" 81 | 82 | case .taskNotInitialized: 83 | return "WebSocket task is not initialized" 84 | case .taskCancelled: 85 | return "WebSocket task was cancelled" 86 | 87 | case .streamAlreadyCreated: 88 | return "WebSocket message stream has already been created" 89 | case .streamNotAvailable: 90 | return "WebSocket message stream is not available" 91 | } 92 | } 93 | } 94 | 95 | // MARK: - CustomStringConvertible for debugging 96 | 97 | extension WebSocketError: CustomStringConvertible { 98 | public var description: String { 99 | errorDescription ?? "Unknown WebSocket error" 100 | } 101 | } 102 | 103 | // MARK: - Equatable (useful for testing) 104 | 105 | extension WebSocketError: Equatable { 106 | public static func == (lhs: WebSocketError, rhs: WebSocketError) -> Bool { 107 | switch (lhs, rhs) { 108 | case (.notConnected, .notConnected), 109 | (.stillConnecting, .stillConnecting), 110 | (.alreadyConnected, .alreadyConnected), 111 | (.connectionTimeout, .connectionTimeout), 112 | (.invalidURL, .invalidURL), 113 | (.invalidMessageFormat, .invalidMessageFormat), 114 | (.messageEncodingFailed, .messageEncodingFailed), 115 | (.messageDecodingFailed, .messageDecodingFailed), 116 | (.pongTimeout, .pongTimeout), 117 | (.forcedDisconnection, .forcedDisconnection), 118 | (.taskNotInitialized, .taskNotInitialized), 119 | (.taskCancelled, .taskCancelled), 120 | (.streamAlreadyCreated, .streamAlreadyCreated), 121 | (.streamNotAvailable, .streamNotAvailable): 122 | return true 123 | 124 | case (.unsupportedProtocol(let lhsProto), .unsupportedProtocol(let rhsProto)): 125 | return lhsProto == rhsProto 126 | 127 | case (.keepAliveFailure(let lhsCount), .keepAliveFailure(let rhsCount)): 128 | return lhsCount == rhsCount 129 | 130 | case (.unexpectedDisconnection(let lhsCode, let lhsReason), 131 | .unexpectedDisconnection(let rhsCode, let rhsReason)): 132 | return lhsCode == rhsCode && lhsReason == rhsReason 133 | 134 | // For errors with underlying errors, compare type names 135 | case (.connectionFailed(let lhsError), .connectionFailed(let rhsError)), 136 | (.sendFailed(let lhsError), .sendFailed(let rhsError)), 137 | (.receiveFailed(let lhsError), .receiveFailed(let rhsError)), 138 | (.pingFailed(let lhsError), .pingFailed(let rhsError)): 139 | return (lhsError as NSError) == (rhsError as NSError) 140 | 141 | default: 142 | return false 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /Sources/EZNetworking/Util/Downloader/FileDownloader.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public class FileDownloader: FileDownloadable { 5 | private let urlSession: URLSessionTaskProtocol 6 | private let validator: ResponseValidator 7 | private let requestDecoder: RequestDecodable 8 | private var sessionDelegate: SessionDelegate 9 | 10 | private let fallbackDownloadTaskInterceptor: DownloadTaskInterceptor = DefaultDownloadTaskInterceptor() 11 | 12 | // MARK: init 13 | 14 | public init( 15 | urlSession: URLSessionTaskProtocol = URLSession.shared, 16 | validator: ResponseValidator = ResponseValidatorImpl(), 17 | requestDecoder: RequestDecodable = RequestDecoder(), 18 | sessionDelegate: SessionDelegate? = nil // Now optional! 19 | ) { 20 | if let urlSession = urlSession as? URLSession { 21 | // If the session already has a delegate, use it (if it's a SessionDelegate) 22 | if let existingDelegate = urlSession.delegate as? SessionDelegate { 23 | self.sessionDelegate = existingDelegate 24 | self.urlSession = urlSession 25 | } else { 26 | // If no delegate or not a SessionDelegate, create one 27 | let newDelegate = sessionDelegate ?? SessionDelegate() 28 | let newSession = URLSession( 29 | configuration: urlSession.configuration, 30 | delegate: newDelegate, 31 | delegateQueue: urlSession.delegateQueue 32 | ) 33 | self.sessionDelegate = newDelegate 34 | self.urlSession = newSession 35 | } 36 | } else { 37 | // For mocks or custom protocol types 38 | self.sessionDelegate = sessionDelegate ?? SessionDelegate() 39 | self.urlSession = urlSession 40 | } 41 | self.validator = validator 42 | self.requestDecoder = requestDecoder 43 | } 44 | 45 | // MARK: Async Await 46 | 47 | public func downloadFile(from serverUrl: URL, progress: DownloadProgressHandler? = nil) async throws -> URL { 48 | try await withCheckedThrowingContinuation { continuation in 49 | performDownloadTask(url: serverUrl, progress: progress) { result in 50 | switch result { 51 | case .success(let success): 52 | continuation.resume(returning: success) 53 | case .failure(let error): 54 | continuation.resume(throwing: error) 55 | } 56 | } 57 | } 58 | } 59 | 60 | // MARK: Completion Handler 61 | 62 | @discardableResult 63 | public func downloadFileTask(from serverUrl: URL, progress: DownloadProgressHandler?, completion: @escaping(DownloadCompletionHandler)) -> URLSessionDownloadTask { 64 | return performDownloadTask(url: serverUrl, progress: progress, completion: completion) 65 | } 66 | 67 | // MARK: Publisher 68 | 69 | public func downloadFilePublisher(from serverUrl: URL, progress: DownloadProgressHandler?) -> AnyPublisher { 70 | Future { promise in 71 | self.performDownloadTask(url: serverUrl, progress: progress) { result in 72 | promise(result) 73 | } 74 | } 75 | .eraseToAnyPublisher() 76 | } 77 | 78 | // MARK: AsyncStream 79 | 80 | public func downloadFileStream(from serverUrl: URL) -> AsyncStream { 81 | AsyncStream { continuation in 82 | // Progress handler yields progress updates to the stream. 83 | let progressHandler: DownloadProgressHandler = { progress in 84 | continuation.yield(.progress(progress)) 85 | } 86 | // Start the download task, yielding completion to the stream. 87 | let task = self.performDownloadTask(url: serverUrl, progress: progressHandler) { result in 88 | switch result { 89 | case .success(let url): 90 | continuation.yield(.success(url)) 91 | case .failure(let error): 92 | continuation.yield(.failure(error)) 93 | } 94 | continuation.finish() 95 | } 96 | // Cancel the task if the stream is terminated. 97 | continuation.onTermination = { @Sendable _ in 98 | task.cancel() 99 | } 100 | } 101 | } 102 | 103 | // MARK: - Core 104 | 105 | @discardableResult 106 | private func performDownloadTask(url: URL, progress: DownloadProgressHandler?, completion: @escaping(DownloadCompletionHandler)) -> URLSessionDownloadTask { 107 | configureProgressTracking(progress: progress) 108 | 109 | let task = urlSession.downloadTask(with: url) { [weak self] localURL, response, error in 110 | guard let self else { 111 | completion(.failure(.internalError(.lostReferenceOfSelf))) 112 | return 113 | } 114 | do { 115 | try self.validator.validateNoError(error) 116 | try self.validator.validateStatus(from: response) 117 | let localURL = try self.validator.validateUrl(localURL) 118 | 119 | completion(.success(localURL)) 120 | } catch { 121 | completion(.failure(mapError(error))) 122 | } 123 | } 124 | task.resume() 125 | return task 126 | } 127 | 128 | private func mapError(_ error: Error) -> NetworkingError { 129 | if let networkError = error as? NetworkingError { return networkError } 130 | if let urlError = error as? URLError { return .urlError(urlError) } 131 | return .internalError(.unknown) 132 | } 133 | 134 | private func configureProgressTracking(progress: DownloadProgressHandler?) { 135 | guard let progress else { return } 136 | 137 | if sessionDelegate.downloadTaskInterceptor != nil { 138 | // Update existing interceptor's progress handler 139 | sessionDelegate.downloadTaskInterceptor?.progress = progress 140 | } else { 141 | // Set up fallback interceptor with progress handler 142 | fallbackDownloadTaskInterceptor.progress = progress 143 | sessionDelegate.downloadTaskInterceptor = fallbackDownloadTaskInterceptor 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Error/WebSocketError/WebSocketErrorTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test WebSocketErrorTests") 6 | final class WebSocketErrorTests { 7 | @Test("test WebSocketError basic cases are Equatable", arguments: zip(WebSocketErrorList, WebSocketErrorList)) 8 | func testBasicCasesAreEquatable(inputA: WebSocketError, inputB: WebSocketError) { 9 | #expect(inputA == inputB) 10 | } 11 | 12 | @Test("test different basic cases are not equal") 13 | func testDifferentBasicCasesAreNotEqual() { 14 | #expect(WebSocketError.notConnected != WebSocketError.alreadyConnected) 15 | #expect(WebSocketError.invalidURL != WebSocketError.invalidMessageFormat) 16 | } 17 | 18 | @Test("test unsupportedProtocol equality and inequality") 19 | func testUnsupportedProtocolEquality() { 20 | let a = WebSocketError.unsupportedProtocol("protoA") 21 | let b = WebSocketError.unsupportedProtocol("protoA") 22 | let c = WebSocketError.unsupportedProtocol("protoB") 23 | #expect(a == b) 24 | #expect(a != c) 25 | } 26 | 27 | @Test("test keepAliveFailure equality by count") 28 | func testKeepAliveFailureEquality() { 29 | #expect(WebSocketError.keepAliveFailure(consecutiveFailures: 3) == .keepAliveFailure(consecutiveFailures: 3)) 30 | #expect(WebSocketError.keepAliveFailure(consecutiveFailures: 2) != .keepAliveFailure(consecutiveFailures: 4)) 31 | } 32 | 33 | @Test("test unexpectedDisconnection equality and description") 34 | func testUnexpectedDisconnectionEqualityAndDescription() { 35 | let lhs = WebSocketError.unexpectedDisconnection(code: .normalClosure, reason: "bye") 36 | let rhs = WebSocketError.unexpectedDisconnection(code: .normalClosure, reason: "bye") 37 | let other = WebSocketError.unexpectedDisconnection(code: .goingAway, reason: "bye") 38 | 39 | #expect(lhs == rhs) 40 | #expect(lhs != other) 41 | 42 | // description should contain code raw value and reason 43 | guard case let .unexpectedDisconnection(code, reason) = lhs else { 44 | #expect(Bool(false)) 45 | return 46 | } 47 | let expected = "WebSocket disconnected unexpectedly with code \(code.rawValue): \(reason ?? "No provided")" 48 | #expect(lhs.errorDescription == expected) 49 | #expect(lhs.description == expected) 50 | } 51 | 52 | @Test("test unexpectedDisconnection equality and description with no reason provided") 53 | func testUnexpectedDisconnectionEqualityAndDescriptionWithNoReasonProvided() { 54 | let err = WebSocketError.unexpectedDisconnection(code: .normalClosure, reason: nil) 55 | 56 | guard case let .unexpectedDisconnection(code, _) = err else { 57 | #expect(Bool(false)) 58 | return 59 | } 60 | 61 | let expected = "WebSocket disconnected unexpectedly with code \(code.rawValue): No reason provided" 62 | #expect(err.errorDescription == expected) 63 | #expect(err.description == expected) 64 | } 65 | 66 | @Test("test underlying error equality compares by type for various cases") 67 | func testUnderlyingErrorEqualityByType() { 68 | let urlErr1 = URLError(.timedOut) 69 | let urlErr2 = URLError(.timedOut) 70 | 71 | #expect(WebSocketError.connectionFailed(underlying: urlErr1) == .connectionFailed(underlying: urlErr2)) 72 | #expect(WebSocketError.sendFailed(underlying: urlErr1) == .sendFailed(underlying: urlErr2)) 73 | #expect(WebSocketError.receiveFailed(underlying: urlErr1) == .receiveFailed(underlying: urlErr2)) 74 | #expect(WebSocketError.pingFailed(underlying: urlErr1) == .pingFailed(underlying: urlErr2)) 75 | } 76 | 77 | @Test("test underlying error equality compares fails if underlying error difers") 78 | func testUnderlyingErrorEqualityByTypeFailsIfErrorDiffers() { 79 | let urlErr = URLError(.timedOut) 80 | let nsErr = NSError(domain: "test", code: 1, userInfo: nil) 81 | 82 | #expect(WebSocketError.connectionFailed(underlying: urlErr) != .connectionFailed(underlying: nsErr)) 83 | } 84 | 85 | @Test("test localized descriptions for a selection of cases") 86 | func testLocalizedDescriptions() { 87 | #expect(WebSocketError.notConnected.errorDescription == "WebSocket is not connected") 88 | #expect(WebSocketError.invalidURL.errorDescription == "Invalid WebSocket URL") 89 | #expect(WebSocketError.invalidMessageFormat.errorDescription == "Invalid WebSocket message format") 90 | #expect(WebSocketError.messageEncodingFailed.errorDescription == "Failed to encode message for WebSocket") 91 | #expect(WebSocketError.messageDecodingFailed.errorDescription == "Failed to decode WebSocket message") 92 | } 93 | 94 | @Test("LocalizedError - dynamic descriptions") 95 | func testLocalizedErrorDynamicDescriptions() { 96 | let err = NSError(domain: "Test", code: -1) 97 | let msg = err.localizedDescription 98 | let protocolString = "bad-protocol" 99 | let count = 3 100 | 101 | #expect(WebSocketError.connectionFailed(underlying: err).errorDescription == "WebSocket connection failed: \(msg)") 102 | #expect(WebSocketError.sendFailed(underlying: err).errorDescription == "Failed to send WebSocket message: \(msg)") 103 | #expect(WebSocketError.receiveFailed(underlying: err).errorDescription == "Failed to receive WebSocket message: \(msg)") 104 | #expect(WebSocketError.pingFailed(underlying: err).errorDescription == "WebSocket ping failed: \(msg)") 105 | 106 | #expect(WebSocketError.unsupportedProtocol(protocolString).errorDescription == "Unsupported WebSocket protocol: \(protocolString)") 107 | #expect(WebSocketError.keepAliveFailure(consecutiveFailures: count).errorDescription == "WebSocket keep-alive failed after \(count) consecutive attempts") 108 | } 109 | 110 | private static let WebSocketErrorList: [WebSocketError] = [ 111 | .notConnected, 112 | .stillConnecting, 113 | .alreadyConnected, 114 | .connectionTimeout, 115 | .invalidURL, 116 | .invalidMessageFormat, 117 | .messageEncodingFailed, 118 | .messageDecodingFailed, 119 | .pongTimeout, 120 | .forcedDisconnection, 121 | .taskNotInitialized, 122 | .taskCancelled, 123 | .streamAlreadyCreated, 124 | .streamNotAvailable, 125 | ] 126 | } 127 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/HTTP/Methods/HTTPMethodTests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Testing 3 | 4 | @Suite("Test HTTPMethod") 5 | final class HTTPMethodTests { 6 | 7 | // MARK: - Individual Method Tests 8 | 9 | @Test("test HTTPMethod.GET raw value") 10 | func testHTTPMethodGetRawValue() { 11 | #expect(HTTPMethod.GET.rawValue == "GET") 12 | } 13 | 14 | @Test("test HTTPMethod.POST raw value") 15 | func testHTTPMethodPOSTRawValue() { 16 | #expect(HTTPMethod.POST.rawValue == "POST") 17 | } 18 | 19 | @Test("test HTTPMethod.PUT raw value") 20 | func testHTTPMethodPUTRawValue() { 21 | #expect(HTTPMethod.PUT.rawValue == "PUT") 22 | } 23 | 24 | @Test("test HTTPMethod.DELETE raw value") 25 | func testHTTPMethodDELETERawValue() { 26 | #expect(HTTPMethod.DELETE.rawValue == "DELETE") 27 | } 28 | 29 | @Test("test HTTPMethod.PATCH raw value") 30 | func testHTTPMethodPATCHRawValue() { 31 | #expect(HTTPMethod.PATCH.rawValue == "PATCH") 32 | } 33 | 34 | @Test("test HTTPMethod.HEAD raw value") 35 | func testHTTPMethodHEADRawValue() { 36 | #expect(HTTPMethod.HEAD.rawValue == "HEAD") 37 | } 38 | 39 | @Test("test HTTPMethod.OPTIONS raw value") 40 | func testHTTPMethodOPTIONSRawValue() { 41 | #expect(HTTPMethod.OPTIONS.rawValue == "OPTIONS") 42 | } 43 | 44 | @Test("test HTTPMethod.TRACE raw value") 45 | func testHTTPMethodTRACERawValue() { 46 | #expect(HTTPMethod.TRACE.rawValue == "TRACE") 47 | } 48 | 49 | @Test("test HTTPMethod.CONNECT raw value") 50 | func testHTTPMethodCONNECTRawValue() { 51 | #expect(HTTPMethod.CONNECT.rawValue == "CONNECT") 52 | } 53 | 54 | // MARK: - Comprehensive Tests 55 | 56 | @Test("test all HTTP methods have correct raw values") 57 | func testAllHTTPMethodsHaveCorrectRawValues() { 58 | let expectedValues: [HTTPMethod: String] = [ 59 | .GET: "GET", 60 | .POST: "POST", 61 | .PUT: "PUT", 62 | .DELETE: "DELETE", 63 | .PATCH: "PATCH", 64 | .HEAD: "HEAD", 65 | .OPTIONS: "OPTIONS", 66 | .TRACE: "TRACE", 67 | .CONNECT: "CONNECT" 68 | ] 69 | 70 | for (method, expectedValue) in expectedValues { 71 | #expect(method.rawValue == expectedValue, "HTTPMethod.\(method) should have raw value '\(expectedValue)'") 72 | } 73 | } 74 | 75 | @Test("test all HTTP methods are uppercase") 76 | func testAllHTTPMethodsAreUppercase() { 77 | for method in HTTPMethod.allCases { 78 | #expect(method.rawValue == method.rawValue.uppercased(), "HTTPMethod.\(method) should be uppercase") 79 | } 80 | } 81 | 82 | @Test("test all HTTP methods are non-empty") 83 | func testAllHTTPMethodsAreNonEmpty() { 84 | for method in HTTPMethod.allCases { 85 | #expect(!method.rawValue.isEmpty, "HTTPMethod.\(method) should have non-empty raw value") 86 | } 87 | } 88 | 89 | @Test("test HTTP methods are unique") 90 | func testHTTPMethodsAreUnique() { 91 | let rawValues = HTTPMethod.allCases.map { $0.rawValue } 92 | let uniqueValues = Set(rawValues) 93 | 94 | #expect(rawValues.count == uniqueValues.count, "All HTTP methods should have unique raw values") 95 | } 96 | 97 | // MARK: - CaseIterable Tests 98 | 99 | @Test("test CaseIterable conformance") 100 | func testCaseIterableConformance() { 101 | let allMethods = HTTPMethod.allCases 102 | #expect(allMethods.count == 9, "Should have 9 HTTP methods") 103 | 104 | // Test that all expected methods are present 105 | let expectedMethods: Set = [.GET, .POST, .PUT, .DELETE, .PATCH, .HEAD, .OPTIONS, .TRACE, .CONNECT] 106 | let actualMethods = Set(allMethods) 107 | 108 | #expect(actualMethods == expectedMethods, "All expected HTTP methods should be present") 109 | } 110 | 111 | // MARK: - HTTP Method Categories Tests 112 | 113 | @Test("test safe HTTP methods") 114 | func testSafeHTTPMethods() { 115 | let safeMethods: [HTTPMethod] = [.GET, .HEAD, .OPTIONS, .TRACE] 116 | 117 | for method in safeMethods { 118 | #expect(isSafeMethod(method), "\(method) should be considered a safe HTTP method") 119 | } 120 | } 121 | 122 | @Test("test idempotent HTTP methods") 123 | func testIdempotentHTTPMethods() { 124 | let idempotentMethods: [HTTPMethod] = [.GET, .PUT, .DELETE, .HEAD, .OPTIONS, .TRACE] 125 | 126 | for method in idempotentMethods { 127 | #expect(isIdempotentMethod(method), "\(method) should be considered an idempotent HTTP method") 128 | } 129 | } 130 | 131 | @Test("test methods that allow request body") 132 | func testMethodsThatAllowRequestBody() { 133 | let bodyAllowedMethods: [HTTPMethod] = [.POST, .PUT, .PATCH, .DELETE] 134 | 135 | for method in bodyAllowedMethods { 136 | #expect(allowsRequestBody(method), "\(method) should allow request body") 137 | } 138 | } 139 | 140 | @Test("test methods that typically don't allow request body") 141 | func testMethodsThatDontAllowRequestBody() { 142 | let noBodyMethods: [HTTPMethod] = [.GET, .HEAD, .OPTIONS, .TRACE, .CONNECT] 143 | 144 | for method in noBodyMethods { 145 | #expect(!allowsRequestBody(method), "\(method) should typically not allow request body") 146 | } 147 | } 148 | 149 | // MARK: - Helper Methods 150 | 151 | private func isSafeMethod(_ method: HTTPMethod) -> Bool { 152 | switch method { 153 | case .GET, .HEAD, .OPTIONS, .TRACE: 154 | return true 155 | case .POST, .PUT, .DELETE, .PATCH, .CONNECT: 156 | return false 157 | } 158 | } 159 | 160 | private func isIdempotentMethod(_ method: HTTPMethod) -> Bool { 161 | switch method { 162 | case .GET, .PUT, .DELETE, .HEAD, .OPTIONS, .TRACE: 163 | return true 164 | case .POST, .PATCH, .CONNECT: 165 | return false 166 | } 167 | } 168 | 169 | private func allowsRequestBody(_ method: HTTPMethod) -> Bool { 170 | switch method { 171 | case .POST, .PUT, .PATCH, .DELETE: 172 | return true 173 | case .GET, .HEAD, .OPTIONS, .TRACE, .CONNECT: 174 | return false 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Interceptors/SessoionDelegate+Extensions+Tests/SessionDelegate+URLSessionTaskDelegate+Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test SessionDelegateURLSessionTaskDelegate") 6 | final class SessionDelegateURLSessionTaskDelegateTests { 7 | 8 | @Test("test SessionDelegate DidReceiveChallenge") 9 | func testSessionDelegateDidReceiveChallenge() async { 10 | let authenticationInterceptor = MockAuthenticationInterceptor() 11 | let delegate = SessionDelegate() 12 | delegate.authenticationInterceptor = authenticationInterceptor 13 | 14 | let (disposition, credential) = await delegate.urlSession(.shared, task: mockURLSessionTask, didReceive: URLAuthenticationChallenge()) 15 | #expect(disposition == .performDefaultHandling) 16 | #expect(credential == nil) 17 | #expect(authenticationInterceptor.didReceiveChallengeWithTask) 18 | } 19 | 20 | @Test("test SessionDelegate DidReceiveChallenge with no interceptor") 21 | func testSessionDelegateDidReceiveChallengeWithNoInterceptor() async { 22 | let delegate = SessionDelegate() 23 | 24 | let (disposition, credential) = await delegate.urlSession(.shared, task: mockURLSessionTask, didReceive: URLAuthenticationChallenge()) 25 | #expect(disposition == .performDefaultHandling) 26 | #expect(credential == nil) 27 | } 28 | 29 | @Test("test SessionDelegate WillPerformHTTPRedirection") 30 | func testSessionDelegateWillPerformHTTPRedirection() async { 31 | let redirectInterceptor = MockRedirectInterceptor() 32 | let delegate = SessionDelegate() 33 | delegate.redirectInterceptor = redirectInterceptor 34 | 35 | let response = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 301, httpVersion: nil, headerFields: nil)! 36 | let request = URLRequest(url: URL(string: "https://redirected.com")!) 37 | 38 | let newRequest = await delegate.urlSession(.shared, task: mockURLSessionTask, willPerformHTTPRedirection: response, newRequest: request) 39 | #expect(newRequest == request) 40 | #expect(redirectInterceptor.didRedirect) 41 | } 42 | 43 | @Test("test SessionDelegate WillPerformHTTPRedirection with no interceptor") 44 | func testSessionDelegateWillPerformHTTPRedirectionWithNoInterceptor() async { 45 | let delegate = SessionDelegate() 46 | 47 | let response = HTTPURLResponse(url: URL(string: "https://example.com")!, statusCode: 301, httpVersion: nil, headerFields: nil)! 48 | let request = URLRequest(url: URL(string: "https://redirected.com")!) 49 | 50 | let newRequest = await delegate.urlSession(.shared, task: mockURLSessionTask, willPerformHTTPRedirection: response, newRequest: request) 51 | #expect(newRequest == request) 52 | } 53 | 54 | @Test("test SessionDelegate DidFinishCollectingMetrics") 55 | func testSessionDelegateDidFinishCollectingMetrics() { 56 | let metricsInterceptor = MockMetricsInterceptor() 57 | let delegate = SessionDelegate() 58 | delegate.metricsInterceptor = metricsInterceptor 59 | 60 | delegate.urlSession(.shared, task: mockURLSessionTask, didFinishCollecting: mockURLSessionTaskMetrics) 61 | 62 | #expect(metricsInterceptor.didCollectMetrics) 63 | } 64 | 65 | @Test("test SessionDelegate DidCompleteWithError") 66 | func testSessionDelegateDidCompleteWithError() { 67 | let taskLifecycleInterceptor = MockTaskLifecycleInterceptor() 68 | let delegate = SessionDelegate() 69 | delegate.taskLifecycleInterceptor = taskLifecycleInterceptor 70 | 71 | let error = NSError(domain: "TestError", code: 1, userInfo: nil) 72 | delegate.urlSession(.shared, task: mockURLSessionTask, didCompleteWithError: error) 73 | 74 | #expect(taskLifecycleInterceptor.didCompleteWithError) 75 | } 76 | 77 | @Test("test SessionDelegateT askIsWaitingForConnectivity") 78 | func testSessionDelegateTaskIsWaitingForConnectivity() { 79 | let taskLifecycleInterceptor = MockTaskLifecycleInterceptor() 80 | let delegate = SessionDelegate() 81 | delegate.taskLifecycleInterceptor = taskLifecycleInterceptor 82 | 83 | delegate.urlSession(.shared, taskIsWaitingForConnectivity: mockURLSessionTask) 84 | 85 | #expect(taskLifecycleInterceptor.taskIsWaitingForConnectivity) 86 | } 87 | 88 | } 89 | 90 | // MARK: Mock classes 91 | 92 | private class MockAuthenticationInterceptor: AuthenticationInterceptor { 93 | var didReceiveChallengeWithTask = false 94 | func urlSession(_ session: URLSession, task: URLSessionTask, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 95 | didReceiveChallengeWithTask = true 96 | return (.performDefaultHandling, nil) 97 | } 98 | 99 | var didReceiveChallenge = false 100 | func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { 101 | didReceiveChallenge = true 102 | return (.performDefaultHandling, nil) 103 | } 104 | } 105 | 106 | private class MockRedirectInterceptor: RedirectInterceptor { 107 | var didRedirect = false 108 | func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? { 109 | didRedirect = true 110 | return request 111 | } 112 | } 113 | 114 | private class MockMetricsInterceptor: MetricsInterceptor { 115 | var didCollectMetrics = false 116 | func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { 117 | didCollectMetrics = true 118 | } 119 | } 120 | 121 | private class MockTaskLifecycleInterceptor: TaskLifecycleInterceptor { 122 | var didCompleteWithError = false 123 | var taskIsWaitingForConnectivity = false 124 | 125 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 126 | didCompleteWithError = true 127 | } 128 | 129 | func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { 130 | taskIsWaitingForConnectivity = true 131 | } 132 | 133 | func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) {} 134 | } 135 | 136 | // MARK: Mock variable 137 | 138 | private var mockURLSessionTask: URLSessionTask { 139 | URLSessionTask() 140 | } 141 | 142 | private var mockURLSessionTaskMetrics: URLSessionTaskMetrics { 143 | URLSessionTaskMetrics() 144 | } 145 | -------------------------------------------------------------------------------- /Tests/EZNetworkingTests/Util/Performers/RequestPerformable_asyncAwait_Tests.swift: -------------------------------------------------------------------------------- 1 | @testable import EZNetworking 2 | import Foundation 3 | import Testing 4 | 5 | @Suite("Test RequestPerformable async/await methods") 6 | final class RequestPerformable_asyncAwait_Tests { 7 | 8 | // MARK: - SUCCESS RESPONSE 9 | 10 | @Test("test perform(request:_, decodeTo:_) with all valid inputs does not throw error") 11 | func perform_withValidInputs_doesNotThrowError() async throws { 12 | let sut = createRequestPerformer() 13 | await #expect(throws: Never.self) { 14 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 15 | } 16 | } 17 | 18 | @Test("test perform(request:_, decodeTo:_) with all valid inputs decodes data") 19 | func perform_withValidInputs_doesDecodeData() async throws { 20 | let sut = createRequestPerformer() 21 | let person = try await sut.perform(request: MockRequest(), decodeTo: Person.self) 22 | #expect(person.name == "John") 23 | #expect(person.age == 30) 24 | } 25 | 26 | @Test("test perform(request:_) with all valid inputs does not throw error") 27 | func perform_withoutDecoding_withValidInputs_doesNotThrowError() async throws { 28 | let sut = createRequestPerformer() 29 | await #expect(throws: Never.self) { 30 | try await sut.perform(request: MockRequest(), decodeTo: EmptyResponse.self) 31 | } 32 | } 33 | 34 | // MARK: - ERROR RESPONSE 35 | 36 | 37 | 38 | // MARK: http status code error tests 39 | 40 | @Test("test perform(request:_) fails when status code is 3xx") 41 | func perform_throwsErrorWhen_statusCodeIs300() async throws { 42 | let sut = createRequestPerformer( 43 | urlSession: createMockURLSession(statusCode: 300) 44 | ) 45 | await #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 300))) { 46 | try await sut.perform(request: MockRequest(), decodeTo: EmptyResponse.self) 47 | } 48 | } 49 | 50 | @Test("test perform(request:_) fails when status code is 4xx") 51 | func perform_throwsErrorWhen_statusCodeIs400() async throws { 52 | let sut = createRequestPerformer( 53 | urlSession: createMockURLSession(statusCode: 400) 54 | ) 55 | await #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 400))) { 56 | try await sut.perform(request: MockRequest(), decodeTo: EmptyResponse.self) 57 | } 58 | } 59 | 60 | @Test("test perform(request:_) fails when status code is 5xx") 61 | func perform_throwsErrorWhen_statusCodeIs500() async throws { 62 | let sut = createRequestPerformer( 63 | urlSession: createMockURLSession(statusCode: 500) 64 | ) 65 | await #expect(throws: NetworkingError.httpError(HTTPError(statusCode: 500))) { 66 | try await sut.perform(request: MockRequest(), decodeTo: EmptyResponse.self) 67 | } 68 | } 69 | 70 | // MARK: URLSession has error 71 | 72 | @Test("test perform(request:_) fails when URLSession throws HTTPClientError") 73 | func perform_throwsErrorWhen_urlSessionThrowsHTTPClientError() async throws { 74 | let sut = createRequestPerformer( 75 | urlSession: createMockURLSession(error: HTTPError(statusCode: 400)) 76 | ) 77 | await #expect(throws: NetworkingError.internalError(.requestFailed(HTTPError(statusCode: 400)))) { 78 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 79 | } 80 | } 81 | 82 | @Test("test perform(request:_) fails when URLSession throws HTTPServerError") 83 | func perform_throwsErrorWhen_urlSessionThrowsHTTPServerError() async throws { 84 | let sut = createRequestPerformer( 85 | urlSession: createMockURLSession(error: HTTPError(statusCode: 500)) 86 | ) 87 | await #expect(throws: NetworkingError.internalError(.requestFailed(HTTPError(statusCode: 500)))) { 88 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 89 | } 90 | } 91 | 92 | @Test("test perform(request:_) fails when URLSession throws URLError") 93 | func perform_throwsErrorWhen_urlSessionThrowsURLError() async throws { 94 | let sut = createRequestPerformer( 95 | urlSession: createMockURLSession(error: URLError(.networkConnectionLost)) 96 | ) 97 | await #expect(throws: NetworkingError.urlError(URLError(.networkConnectionLost))) { 98 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 99 | } 100 | } 101 | 102 | @Test("test perform(request:_) fails when URLSession throws unknown error") 103 | func perform_throwsErrorWhen_urlSessionThrowsUnknownError() async throws { 104 | enum UnknownError: Error { 105 | case unknownError 106 | } 107 | let sut = createRequestPerformer( 108 | urlSession: createMockURLSession(error: UnknownError.unknownError) 109 | ) 110 | await #expect(throws: NetworkingError.internalError(.requestFailed(UnknownError.unknownError))) { 111 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 112 | } 113 | } 114 | 115 | // MARK: data deocding errors 116 | 117 | @Test("test perform(request:_, decodeTo:_) fails when data is nil") 118 | func performAndDecode_throwsErrorWhen_dataIsNil() async throws { 119 | let sut = createRequestPerformer( 120 | urlSession: createMockURLSession(data: nil) 121 | ) 122 | await #expect(throws: NetworkingError.internalError(.noData)) { 123 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 124 | } 125 | } 126 | 127 | @Test("test perform(request:_, decodeTo:_) fails data does not match decodeTo type") 128 | func performAndDecode_throwsErrorWhen_dataDoesNotMatchDecodeToType() async throws { 129 | let sut = createRequestPerformer( 130 | urlSession: createMockURLSession(data: MockData.invalidMockPersonJsonData) 131 | ) 132 | await #expect(throws: NetworkingError.internalError(.couldNotParse)) { 133 | try await sut.perform(request: MockRequest(), decodeTo: Person.self) 134 | } 135 | } 136 | } 137 | 138 | // MARK: - helpers 139 | 140 | private func createRequestPerformer( 141 | urlSession: URLSessionTaskProtocol = createMockURLSession(), 142 | validator: ResponseValidator = ResponseValidatorImpl(), 143 | requestDecoder: RequestDecodable = RequestDecoder() 144 | ) -> RequestPerformer { 145 | return RequestPerformer(urlSession: urlSession, validator: validator, requestDecoder: requestDecoder) 146 | } 147 | 148 | private func createMockURLSession( 149 | data: Data? = MockData.mockPersonJsonData, 150 | statusCode: Int = 200, 151 | error: Error? = nil 152 | ) -> MockRequestPerformerURLSession { 153 | return MockRequestPerformerURLSession( 154 | data: data, 155 | urlResponse: buildResponse(statusCode: statusCode), 156 | error: error 157 | ) 158 | } 159 | 160 | private func buildResponse(statusCode: Int) -> HTTPURLResponse { 161 | HTTPURLResponse(url: URL(string: "https://example.com")!, 162 | statusCode: statusCode, 163 | httpVersion: nil, 164 | headerFields: nil)! 165 | } 166 | 167 | private struct MockRequest: Request { 168 | var httpMethod: HTTPMethod { .GET } 169 | var baseUrlString: String { "https://www.example.com" } 170 | var parameters: [HTTPParameter]? { nil } 171 | var headers: [HTTPHeader]? { nil } 172 | var body: HTTPBody? { nil } 173 | } 174 | 175 | private struct MockRequestWithNilBuild: Request { 176 | var httpMethod: HTTPMethod { .GET } 177 | var baseUrlString: String { "https://www.example.com" } 178 | var parameters: [HTTPParameter]? { nil } 179 | var headers: [HTTPHeader]? { nil } 180 | var body: HTTPBody? { nil } 181 | var urlRequest: URLRequest? { nil } 182 | } 183 | --------------------------------------------------------------------------------