From 32b2fab3c2fbc49cec9a96dfeadb271893074c23 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 23:18:48 +0000 Subject: [PATCH 1/3] Add first-class async/await networking API Modernizes the library around Swift concurrency while keeping the existing completion handler API intact: - New AsyncRequestManager (backed by the AsyncRequestable protocol) offering a vanilla async request, CRUD conveniences (create/read/update/delete), download and upload APIs, plus a Decodable-returning request built on top of a configurable decoder - Downloaded files are relocated out of URLSession's ephemeral storage so they remain usable after the call returns - New PATCH case in HTTPMethod, surfaced through update(_:payload:method:) - Extracted shared HTTP response validation into ResponseValidator so every API flavor maps NetworkError consistently - Fixed URLSession.dataTaskWithURL dropping the request's HTTP method, headers and body by forwarding only its URL - URLSessionable now covers data, download and upload tasks; its completion typealiases are @Sendable to match URLSession's contract - Bumped tools version to 5.7, declared tvOS/watchOS floors and made the package build on Linux (FoundationNetworking imports, Combine agent conditionally compiled, stale LinuxMain/XCTestManifests removed in favor of automatic test discovery) - Unit tests for every async use case (success, transport failure, HTTP error mapping, CRUD verb stamping, decoding, upload recording, download persistence) - README overhauled with SPM installation steps and async/await usage examples Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017esYpGrJ3HtXUTaGiJ5jff --- Package.swift | 6 +- README.md | 164 ++++++++++- Sources/MauriNet/Combine/Agent.swift | 2 + .../Concurrency/AsyncRequestManager.swift | 212 ++++++++++++++ .../Concurrency/AsyncRequestable.swift | 34 +++ Sources/MauriNet/Global/Constants.swift | 3 + Sources/MauriNet/Global/HTTPMethods.swift | 3 + Sources/MauriNet/URLSession/APIEndpoint.swift | 3 + .../MauriNet/URLSession/EndpointBuilder.swift | 3 + .../MauriNet/URLSession/RequestManager.swift | 25 +- .../URLSession/RequestableManager.swift | 44 ++- .../URLSession/ResponseValidator.swift | 75 +++++ Tests/LinuxMain.swift | 7 - .../MauriNetTests/Setup/XCTestManifests.swift | 9 - .../TestDoubles/Dummies/DummyDecodable.swift | 5 +- .../TestDoubles/Mocks/SessionMock.swift | 112 +++++++- .../Mocks/URLSessionDataTaskMock.swift | 33 ++- .../Tests/AsyncRequestManagerTestCases.swift | 263 ++++++++++++++++++ .../Tests/HTTPMethodTestCases.swift | 19 ++ .../Tests/MauriNetSessionTestCases.swift | 3 + .../Tests/MauriNetURLBuilderTestCases.swift | 3 + 21 files changed, 975 insertions(+), 53 deletions(-) create mode 100644 Sources/MauriNet/Concurrency/AsyncRequestManager.swift create mode 100644 Sources/MauriNet/Concurrency/AsyncRequestable.swift create mode 100644 Sources/MauriNet/URLSession/ResponseValidator.swift delete mode 100644 Tests/LinuxMain.swift delete mode 100644 Tests/MauriNetTests/Setup/XCTestManifests.swift create mode 100644 Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift create mode 100644 Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift diff --git a/Package.swift b/Package.swift index 763f06a..2961b1b 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.7 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -7,7 +7,9 @@ let package = Package( name: "MauriNet", platforms: [ .iOS(.v13), - .macOS(SupportedPlatform.MacOSVersion.v10_15) + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/README.md b/README.md index a88a82c..2656166 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,167 @@ # [MauriNet](https://github.com/GeekingwithMauri/MauriNet) -Thin layer for standard network handling +Thin layer for standard network handling, with **async/await as a first class citizen**. -- Wrapper around Foundation's `URLSession` and all of its settings -- WIP: Combine wrapper for chained requests +- ✅ Vanilla async/await wrappers for CRUD network operations (`create` / `read` / `update` / `delete`) +- ✅ async/await download and upload APIs +- ✅ Type-safe `Decodable` requests out of the box +- ✅ Endpoint/URL assembling helpers +- ✅ Legacy completion handler support (`RequestManager`) +- ✅ 100% `URLSession` under the hood. Zero third-party dependencies +- ✅ Fully unit tested + +## Requirements + +- Swift 5.7+ +- iOS 13+ / macOS 10.15+ / tvOS 13+ / watchOS 6+ + +## Installation + +### Swift Package Manager + +In Xcode: _File ▸ Add Package Dependencies..._ and paste + +``` +https://github.com/GeekingwithMauri/MauriNet +``` + +Or declare it directly in your `Package.swift`: + +```swift +dependencies: [ + .package(url: "https://github.com/GeekingwithMauri/MauriNet", branch: "main") +], +targets: [ + .target(name: "YourTarget", dependencies: ["MauriNet"]) +] +``` + +## Usage + +### CRUD operations with async/await + +`AsyncRequestManager` is the modern entry point of the library. Its CRUD conveniences take any `URLRequest` and stamp the appropriate HTTP verb on it for you: + +```swift +import MauriNet + +let manager = AsyncRequestManager() +let postsRequest = URLRequest(url: URL(validURL: "https://example.com/api/posts")) + +// Create (POST) +let creationReply = try await manager.create(postsRequest, payload: encodedNewPost) + +// Read (GET) +let retrievedData = try await manager.read(postsRequest) + +// Update (PUT by default, PATCH on demand) +let updateReply = try await manager.update(postsRequest, payload: encodedAmendedPost) +let patchReply = try await manager.update(postsRequest, payload: encodedDelta, method: .patch) + +// Delete (DELETE) +try await manager.delete(postsRequest) +``` + +Any failure throws a strongly typed `NetworkError`, mapped from the reply's HTTP status code: + +```swift +do { + let retrievedData = try await manager.read(postsRequest) +} catch let error as NetworkError { + switch error { + case .notFound: + // 404: handle the missing resource + case .unauthorized: + // 401: refresh credentials + default: + print(error.localizedDescription) + } +} +``` + +### Decodable requests + +Skip the manual decoding dance. Declare your model as the return type and let the manager handle the rest: + +```swift +struct Post: Decodable { + let id: Int + let title: String +} + +let posts: [Post] = try await manager.request(postsRequest) +``` + +A custom-configured `JSONDecoder` can be passed as a second argument when needed: + +```swift +let decoder = JSONDecoder() +decoder.keyDecodingStrategy = .convertFromSnakeCase + +let posts: [Post] = try await manager.request(postsRequest, decoder: decoder) +``` + +### Downloads + +```swift +let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) +let localFile = try await manager.download(resourceRequest) +// localFile points to the app's temporary directory: move it to a +// permanent location (or consume it) at your earliest convenience +``` + +### Uploads + +```swift +var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) +uploadRequest.httpMethod = HTTPMethod.post.value + +let serverReply = try await manager.upload(uploadRequest, from: avatarData) +``` + +### Assembling endpoints + +`APIEndpoint` and `EndpointBuilder` remove the string concatenation boilerplate when building requests: + +```swift +let githubEndpoint = APIEndpoint(host: "api.github.com") +let builder = EndpointBuilder(endpointSetup: githubEndpoint) + +// https://api.github.com/events?page=2 +let eventsRequest = builder.assembleRequest(path: "events", queryParameters: ["page": "2"]) +let events: [Event] = try await AsyncRequestManager().request(eventsRequest) +``` + +### Legacy completion handlers + +The original closure-based API remains available for codebases that haven't migrated to Swift concurrency yet: + +```swift +let sessionManager = RequestManager() + +sessionManager.request(eventsRequest) { result in + switch result { + case .success(let data): + // do whatever you wish with the retrieved data + case .failure(let error): + // handle the strongly typed NetworkError + } +} +``` + +### Testability + +Both `AsyncRequestManager` and `RequestManager` are backed by the `URLSessionable` protocol (which `URLSession` already conforms to). Inject your own conforming double to unit test your networking layer without hitting the actual network: + +```swift +struct StubbedSession: URLSessionable { + // return canned data/download/upload tasks +} + +let testableManager = AsyncRequestManager(session: StubbedSession()) +``` # Documentation 🔎📜 -All the documentation was generated with [Jazzy](https://github.com/realm/jazzy) as explained in [this article](https://geekingwithmauri.com/swift/documentation-in-practice.html) and can be found [here](https://geekingwithmauri.github.io/MauriNet/) +All public signatures are fully documented. Reference documentation was generated with [Jazzy](https://github.com/realm/jazzy) as explained in [this article](https://geekingwithmauri.com/swift/documentation-in-practice.html) and can be found [here](https://geekingwithmauri.github.io/MauriNet/) diff --git a/Sources/MauriNet/Combine/Agent.swift b/Sources/MauriNet/Combine/Agent.swift index e019cc9..996a9f0 100644 --- a/Sources/MauriNet/Combine/Agent.swift +++ b/Sources/MauriNet/Combine/Agent.swift @@ -5,6 +5,7 @@ // Created by Mauricio Chirino on 29/11/20. // +#if canImport(Combine) import Combine import Foundation @@ -63,3 +64,4 @@ struct Agent { // Add a more general agent that only returns data and that doesn't assume Main thread handling by default. // Check here https://heckj.github.io/swiftui-notes/ +#endif diff --git a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift new file mode 100644 index 0000000..6b06028 --- /dev/null +++ b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift @@ -0,0 +1,212 @@ +// +// AsyncRequestManager.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Wrapper object that handles network sessions through Swift concurrency (async/await) +public struct AsyncRequestManager: AsyncRequestable { + private let session: URLSessionable + + /// Initializes the network session + /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` + public init(session: URLSessionable = URLSession.shared) { + self.session = session + } + + /// Triggers a generic request, suspending until the remote server replies + /// + /// Example of usage: + /// + /// let endpointRequest = URLRequest(url: URL(validURL: "https://github.com")) + /// let sessionManager = AsyncRequestManager() + /// let retrievedData = try await sessionManager.request(endpointRequest) + /// + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + public func request(_ request: URLRequest) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + session.dataTaskWithURL(request) { data, response, error in + continuation.resume(with: ResponseValidator.validate(data: data, response: response, error: error)) + }.resume() + } + } + + /// Triggers a generic request, decoding the remote server's reply into the inferred `Decodable` type + /// + /// Example of usage: + /// + /// struct Event: Decodable { ... } + /// + /// let endpointRequest = URLRequest(url: URL(validURL: "https://api.github.com/events")) + /// let sessionManager = AsyncRequestManager() + /// let events: [Event] = try await sessionManager.request(endpointRequest) + /// + /// - Parameters: + /// - request: URL request specifications + /// - decoder: decoding strategy for the reply. Defaults to a plain `JSONDecoder` instance + /// - Returns: decoded object from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request or `DecodingError` should the reply not match the expected type + public func request(_ request: URLRequest, + decoder: JSONDecoder = JSONDecoder()) async throws -> Model { + let retrievedData = try await self.request(request) + + return try decoder.decode(Model.self, from: retrievedData) + } + + /// Downloads the resource at the given request, suspending until it's fully retrieved + /// + /// The downloaded file is moved out of `URLSession`'s ephemeral storage into the app's temporary + /// directory, so it remains available after this method returns. Move it to a permanent location + /// (or consume it) at your earliest convenience since the system may eventually purge temporary files. + /// + /// Example of usage: + /// + /// let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) + /// let sessionManager = AsyncRequestManager() + /// let localFile = try await sessionManager.download(resourceRequest) + /// + /// - Parameter request: URL request specifications + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + public func download(_ request: URLRequest) async throws -> URL { + try await withCheckedThrowingContinuation { continuation in + session.downloadTaskWithURL(request) { location, response, error in + let validation = ResponseValidator.validate(location: location, response: response, error: error) + + switch validation { + case .success(let temporaryLocation): + // The temporary file only lives until this completion returns; relocate it so callers can use it + do { + continuation.resume(returning: try persistedCopy(of: temporaryLocation)) + } catch { + continuation.resume(throwing: error) + } + case .failure(let networkError): + continuation.resume(throwing: networkError) + } + }.resume() + } + } + + /// Uploads the given payload as the request's body, suspending until the remote server replies + /// + /// Example of usage: + /// + /// var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) + /// uploadRequest.httpMethod = HTTPMethod.post.value + /// let sessionManager = AsyncRequestManager() + /// let serverReply = try await sessionManager.upload(uploadRequest, from: avatarData) + /// + /// - Parameters: + /// - request: URL request specifications. Make sure its `httpMethod` is set accordingly (i.e. `POST` or `PUT`) + /// - payload: raw bytes to be uploaded to the remote server + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + public func upload(_ request: URLRequest, from payload: Data) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + session.uploadTaskWithURL(request, from: payload) { data, response, error in + continuation.resume(with: ResponseValidator.validate(data: data, response: response, error: error)) + }.resume() + } + } + + private func persistedCopy(of temporaryLocation: URL) throws -> URL { + let fileManager = FileManager.default + let enduringLocation = fileManager.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(temporaryLocation.pathExtension) + + try fileManager.moveItem(at: temporaryLocation, to: enduringLocation) + + return enduringLocation + } +} + +// MARK: - CRUD conveniences + +public extension AsyncRequestManager { + /// **C**RUD convenience: triggers the given request as a `POST`, attaching the payload -if any- as its body + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let newResource = URLRequest(url: URL(validURL: "https://example.com/posts")) + /// let serverReply = try await sessionManager.create(newResource, payload: encodedPost) + /// + /// - Parameters: + /// - request: URL request specifications. Its `httpMethod` will be overridden with `POST` + /// - payload: raw bytes to attach as the request's body. Defaults to `nil` (empty body) + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func create(_ request: URLRequest, payload: Data? = nil) async throws -> Data { + try await self.request(assembled(from: request, method: .post, payload: payload)) + } + + /// C**R**UD convenience: triggers the given request as a `GET` + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// let retrievedData = try await sessionManager.read(existingResource) + /// + /// - Parameter request: URL request specifications. Its `httpMethod` will be overridden with `GET` + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + func read(_ request: URLRequest) async throws -> Data { + try await self.request(assembled(from: request, method: .get, payload: nil)) + } + + /// CR**U**D convenience: triggers the given request as either a `PUT` or a `PATCH`, attaching the payload -if any- as its body + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// let serverReply = try await sessionManager.update(existingResource, payload: encodedPost) + /// + /// - Parameters: + /// - request: URL request specifications. Its `httpMethod` will be overridden with the given method + /// - payload: raw bytes to attach as the request's body. Defaults to `nil` (empty body) + /// - method: HTTP method for the update. Defaults to `.put`; pass `.patch` for partial updates + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func update(_ request: URLRequest, payload: Data? = nil, method: HTTPMethod = .put) async throws -> Data { + try await self.request(assembled(from: request, method: method, payload: payload)) + } + + /// CRU**D** convenience: triggers the given request as a `DELETE` + /// + /// Example of usage: + /// + /// let sessionManager = AsyncRequestManager() + /// let existingResource = URLRequest(url: URL(validURL: "https://example.com/posts/1")) + /// try await sessionManager.delete(existingResource) + /// + /// - Parameter request: URL request specifications. Its `httpMethod` will be overridden with `DELETE` + /// - Returns: raw bytes from the remote server's reply, should it provide any + /// - Throws: `NetworkError` describing what went wrong with the request + @discardableResult + func delete(_ request: URLRequest) async throws -> Data { + try await self.request(assembled(from: request, method: .delete, payload: nil)) + } + + private func assembled(from request: URLRequest, method: HTTPMethod, payload: Data?) -> URLRequest { + var crudRequest = request + crudRequest.httpMethod = method.value + + if let payload = payload { + crudRequest.httpBody = payload + } + + return crudRequest + } +} diff --git a/Sources/MauriNet/Concurrency/AsyncRequestable.swift b/Sources/MauriNet/Concurrency/AsyncRequestable.swift new file mode 100644 index 0000000..04876ab --- /dev/null +++ b/Sources/MauriNet/Concurrency/AsyncRequestable.swift @@ -0,0 +1,34 @@ +// +// AsyncRequestable.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Network manager type for Swift concurrency (async/await) +public protocol AsyncRequestable { + /// Triggers a generic request, suspending until the remote server replies + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server + /// - Throws: `NetworkError` describing what went wrong with the request + func request(_ request: URLRequest) async throws -> Data + + /// Downloads the resource at the given request, suspending until it's fully retrieved + /// - Parameter request: URL request specifications + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + func download(_ request: URLRequest) async throws -> URL + + /// Uploads the given payload as the request's body, suspending until the remote server replies + /// - Parameters: + /// - request: URL request specifications. Make sure its `httpMethod` is set accordingly (i.e. `POST` or `PUT`) + /// - payload: raw bytes to be uploaded to the remote server + /// - Returns: raw bytes from the remote server's reply + /// - Throws: `NetworkError` describing what went wrong with the request + func upload(_ request: URLRequest, from payload: Data) async throws -> Data +} diff --git a/Sources/MauriNet/Global/Constants.swift b/Sources/MauriNet/Global/Constants.swift index 70b0584..448b43c 100644 --- a/Sources/MauriNet/Global/Constants.swift +++ b/Sources/MauriNet/Global/Constants.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Custom type for resulting object from `APIEndpoint` built request public typealias URLBuilderResult = Result diff --git a/Sources/MauriNet/Global/HTTPMethods.swift b/Sources/MauriNet/Global/HTTPMethods.swift index a17e87d..73c974f 100644 --- a/Sources/MauriNet/Global/HTTPMethods.swift +++ b/Sources/MauriNet/Global/HTTPMethods.swift @@ -18,6 +18,9 @@ public enum HTTPMethod { /// Representation of [PUT](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PUT) method case put + /// Representation of [PATCH](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/PATCH) method + case patch + /// Representation of [DELETE](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/DELETE) method case delete diff --git a/Sources/MauriNet/URLSession/APIEndpoint.swift b/Sources/MauriNet/URLSession/APIEndpoint.swift index 8f987c4..f432757 100644 --- a/Sources/MauriNet/URLSession/APIEndpoint.swift +++ b/Sources/MauriNet/URLSession/APIEndpoint.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Helper object that centralize all required parameters for an endpoint public struct APIEndpoint { diff --git a/Sources/MauriNet/URLSession/EndpointBuilder.swift b/Sources/MauriNet/URLSession/EndpointBuilder.swift index 7c6e26c..29a6668 100644 --- a/Sources/MauriNet/URLSession/EndpointBuilder.swift +++ b/Sources/MauriNet/URLSession/EndpointBuilder.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Convenience endpoint builder handler public struct EndpointBuilder { diff --git a/Sources/MauriNet/URLSession/RequestManager.swift b/Sources/MauriNet/URLSession/RequestManager.swift index ca18e55..601653f 100644 --- a/Sources/MauriNet/URLSession/RequestManager.swift +++ b/Sources/MauriNet/URLSession/RequestManager.swift @@ -1,15 +1,17 @@ // // RequestManager.swift -// +// // // Created by Mauricio Chirino on 05/12/20. // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Wrapper object that handles network session public struct RequestManager: RequestableManager { - private let httpResponseCodeSuccessRange = (200..<300) private let session: URLSessionable /// Initializes the network session @@ -37,24 +39,7 @@ public struct RequestManager: RequestableManager { /// - completion: Resulting completion closure from request public func request(_ request: URLRequest, completion: @escaping NetworkResult) { session.dataTaskWithURL(request) { data, response, error in - guard let requestResponse = response as? HTTPURLResponse else { - debugPrint("Unable to parse response due to \(String(describing: error?.localizedDescription))") - completion(.failure(.malformedRequest)) - return - } - - guard let retrievedData = data else { - debugPrint("Unable to parse data due to \(String(describing: error?.localizedDescription))") - completion(.failure(.forbiddenResource)) - - return - } - - if httpResponseCodeSuccessRange.contains(requestResponse.statusCode) { - completion(.success(retrievedData)) - } else { - completion(.failure(NetworkError.buildError(from: requestResponse.statusCode))) - } + completion(ResponseValidator.validate(data: data, response: response, error: error)) }.resume() } } diff --git a/Sources/MauriNet/URLSession/RequestableManager.swift b/Sources/MauriNet/URLSession/RequestableManager.swift index 82912e3..eb2787b 100644 --- a/Sources/MauriNet/URLSession/RequestableManager.swift +++ b/Sources/MauriNet/URLSession/RequestableManager.swift @@ -1,27 +1,65 @@ // // RequestableManager.swift -// +// // // Created by Mauricio Chirino on 07/12/20. // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif /// Custom `Result` object for type of networking result public typealias NetworkResult = (Result) -> Void /// Wrapper for encapsulating Foundation native response values from Data task -public typealias DataTaskResult = (Data?, URLResponse?, Error?) -> Void +public typealias DataTaskResult = @Sendable (Data?, URLResponse?, Error?) -> Void + +/// Wrapper for encapsulating Foundation native response values from Download task +public typealias DownloadTaskResult = @Sendable (URL?, URLResponse?, Error?) -> Void extension URLSession: URLSessionable { public func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - dataTask(with: request.url!, completionHandler: completion) + dataTask(with: request, completionHandler: completion) + } + + public func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { + downloadTask(with: request, completionHandler: completion) + } + + public func uploadTaskWithURL(_ request: URLRequest, + from payload: Data, + completion: @escaping DataTaskResult) -> URLSessionUploadTask { + uploadTask(with: request, from: payload, completionHandler: completion) } } /// Network session type public protocol URLSessionable { + /// Creates a data task honoring the whole `URLRequest` specification (HTTP method, headers, body and timeout) + /// - Parameters: + /// - request: URL request specifications + /// - completion: Foundation's native data task response values + /// - Returns: the assembled session data task. Remember invoking `resume()` on it to kick off the request func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask + + /// Creates a download task that stores the retrieved resource in a temporary file + /// - Parameters: + /// - request: URL request specifications + /// - completion: Foundation's native download task response values + /// - Returns: the assembled session download task. Remember invoking `resume()` on it to kick off the request + func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask + + /// Creates an upload task that sends the given payload as the request's body + /// - Parameters: + /// - request: URL request specifications + /// - payload: raw bytes to be uploaded to the remote server + /// - completion: Foundation's native data task response values + /// - Returns: the assembled session upload task. Remember invoking `resume()` on it to kick off the request + func uploadTaskWithURL(_ request: URLRequest, + from payload: Data, + completion: @escaping DataTaskResult) -> URLSessionUploadTask } /// Network manager type diff --git a/Sources/MauriNet/URLSession/ResponseValidator.swift b/Sources/MauriNet/URLSession/ResponseValidator.swift new file mode 100644 index 0000000..a8b031e --- /dev/null +++ b/Sources/MauriNet/URLSession/ResponseValidator.swift @@ -0,0 +1,75 @@ +// +// ResponseValidator.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +/// Internal helper that centralizes HTTP response validation for every flavor of request +/// (completion handler based, Combine and async/await), guaranteeing consistent error mapping +enum ResponseValidator { + static let httpResponseCodeSuccessRange = (200..<300) + + /// Validates a raw data task response, mapping it into the library's `Result` type + /// - Parameters: + /// - data: raw bytes retrieved from the remote server, if any + /// - response: HTTP response metadata, if any + /// - error: transport error surfaced by `URLSession`, if any + /// - Returns: either the retrieved `Data` or the mapped `NetworkError` + static func validate(data: Data?, response: URLResponse?, error: Error?) -> Result { + switch validateResponse(response, error: error) { + case .success(let httpResponse): + guard let retrievedData = data else { + debugPrint("Unable to parse data due to \(String(describing: error?.localizedDescription))") + return .failure(.forbiddenResource) + } + + if httpResponseCodeSuccessRange.contains(httpResponse.statusCode) { + return .success(retrievedData) + } else { + return .failure(NetworkError.buildError(from: httpResponse.statusCode)) + } + case .failure(let networkError): + return .failure(networkError) + } + } + + /// Validates a raw download task response, mapping it into the library's `Result` type + /// - Parameters: + /// - location: temporary file location where `URLSession` stored the downloaded resource, if any + /// - response: HTTP response metadata, if any + /// - error: transport error surfaced by `URLSession`, if any + /// - Returns: either the temporary file `URL` or the mapped `NetworkError` + static func validate(location: URL?, response: URLResponse?, error: Error?) -> Result { + switch validateResponse(response, error: error) { + case .success(let httpResponse): + // Unlike data tasks, an HTTP error here never yields a usable file: map the status code first + guard httpResponseCodeSuccessRange.contains(httpResponse.statusCode) else { + return .failure(NetworkError.buildError(from: httpResponse.statusCode)) + } + + guard let retrievedLocation = location else { + debugPrint("Unable to retrieve file due to \(String(describing: error?.localizedDescription))") + return .failure(.forbiddenResource) + } + + return .success(retrievedLocation) + case .failure(let networkError): + return .failure(networkError) + } + } + + private static func validateResponse(_ response: URLResponse?, error: Error?) -> Result { + guard let httpResponse = response as? HTTPURLResponse else { + debugPrint("Unable to parse response due to \(String(describing: error?.localizedDescription))") + return .failure(.malformedRequest) + } + + return .success(httpResponse) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift deleted file mode 100644 index e2db000..0000000 --- a/Tests/LinuxMain.swift +++ /dev/null @@ -1,7 +0,0 @@ -import XCTest - -import MauriNetTests - -var tests = [XCTestCaseEntry]() -tests += MauriNetTests.allTests() -XCTMain(tests) diff --git a/Tests/MauriNetTests/Setup/XCTestManifests.swift b/Tests/MauriNetTests/Setup/XCTestManifests.swift deleted file mode 100644 index f83f786..0000000 --- a/Tests/MauriNetTests/Setup/XCTestManifests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import XCTest - -#if !canImport(ObjectiveC) -public func allTests() -> [XCTestCaseEntry] { - return [ - testCase(MauriNetTests.allTests), - ] -} -#endif diff --git a/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift b/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift index 32cc979..46ee869 100644 --- a/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift +++ b/Tests/MauriNetTests/TestDoubles/Dummies/DummyDecodable.swift @@ -1,11 +1,12 @@ // // DummyDecodable.swift -// +// // // Created by Mauricio Chirino on 29/11/20. // import Foundation -struct DummyDecodable: Decodable { +struct DummyDecodable: Decodable, Equatable { + let message: String } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift index 708351a..cdb6152 100644 --- a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift +++ b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift @@ -1,11 +1,14 @@ // // URLFailingSessionMock.swift -// +// // // Created by Mauricio Chirino on 29/11/20. // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import MauriNet /// URL session mocking implementation during testing. @@ -16,6 +19,20 @@ struct URLFailingSessionMock: URLSessionable { completion(nil, nil, nil) } } + + func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { + return URLSessionDownloadTaskMock { + completion(nil, nil, nil) + } + } + + func uploadTaskWithURL(_ request: URLRequest, + from payload: Data, + completion: @escaping DataTaskResult) -> URLSessionUploadTask { + return URLSessionUploadTaskMock { + completion(nil, nil, nil) + } + } } struct URLSuccessSessionMock: URLSessionable { @@ -23,11 +40,98 @@ struct URLSuccessSessionMock: URLSessionable { return URLSessionDataTaskMock { let dummyData = "dummyData".data(using: .utf8)! let response = HTTPURLResponse(url: request.url!, - mimeType: nil, - expectedContentLength: 10, - textEncodingName: nil) + statusCode: 200, + httpVersion: nil, + headerFields: nil) completion(dummyData, response, nil) } } + + func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { + return URLSessionDownloadTaskMock { + let response = HTTPURLResponse(url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + + completion(FileManager.default.temporaryDirectory, response, nil) + } + } + + func uploadTaskWithURL(_ request: URLRequest, + from payload: Data, + completion: @escaping DataTaskResult) -> URLSessionUploadTask { + return URLSessionUploadTaskMock { + let response = HTTPURLResponse(url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil) + + completion(payload, response, nil) + } + } +} + +/// Fully configurable session mock: stubs replies per task flavor and records +/// the last request/payload that went through it for later assertion +final class ConfigurableSessionMock: URLSessionable { + /// Data returned on data/upload task completions. Defaults to a non empty dummy + var stubbedData: Data? = "dummyData".data(using: .utf8) + + /// HTTP status code stamped on the stubbed response. Defaults to `200` + var stubbedStatusCode: Int = 200 + + /// When `false`, completions reply with a `nil` response (as in transport failures). Defaults to `true` + var repliesWithHTTPResponse = true + + /// Temporary file location returned on download task completions. Defaults to `nil` + var stubbedDownloadLocation: URL? + + /// Last request that went through any of the session's tasks + private(set) var lastRequest: URLRequest? + + /// Last payload that went through the session's upload task + private(set) var lastUploadedPayload: Data? + + func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { + lastRequest = request + + return URLSessionDataTaskMock { [weak self] in + guard let self = self else { return } + completion(self.stubbedData, self.assembleResponse(for: request), nil) + } + } + + func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { + lastRequest = request + + return URLSessionDownloadTaskMock { [weak self] in + guard let self = self else { return } + completion(self.stubbedDownloadLocation, self.assembleResponse(for: request), nil) + } + } + + func uploadTaskWithURL(_ request: URLRequest, + from payload: Data, + completion: @escaping DataTaskResult) -> URLSessionUploadTask { + lastRequest = request + lastUploadedPayload = payload + + return URLSessionUploadTaskMock { [weak self] in + guard let self = self else { return } + completion(self.stubbedData, self.assembleResponse(for: request), nil) + } + } + + private func assembleResponse(for request: URLRequest) -> URLResponse? { + guard repliesWithHTTPResponse else { + return nil + } + + return HTTPURLResponse(url: request.url!, + statusCode: stubbedStatusCode, + httpVersion: nil, + headerFields: nil) + } } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift index 8103a00..51bca51 100644 --- a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift +++ b/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift @@ -1,11 +1,14 @@ // // URLSessionDataTaskMock.swift -// +// // // Created by Mauricio Chirino on 29/11/20. // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif final class URLSessionDataTaskMock: URLSessionDataTask { private let closure: () -> Void @@ -14,7 +17,33 @@ final class URLSessionDataTaskMock: URLSessionDataTask { self.closure = closure } - /// Fulfills the provided closure immediately + /// Fulfills the provided closure immediately + override func resume() { + closure() + } +} + +final class URLSessionDownloadTaskMock: URLSessionDownloadTask { + private let closure: () -> Void + + init(closure: @escaping () -> Void) { + self.closure = closure + } + + /// Fulfills the provided closure immediately + override func resume() { + closure() + } +} + +final class URLSessionUploadTaskMock: URLSessionUploadTask { + private let closure: () -> Void + + init(closure: @escaping () -> Void) { + self.closure = closure + } + + /// Fulfills the provided closure immediately override func resume() { closure() } diff --git a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift new file mode 100644 index 0000000..47d9a3a --- /dev/null +++ b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift @@ -0,0 +1,263 @@ +// +// AsyncRequestManagerTestCases.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@testable import MauriNet + +final class AsyncRequestManagerTestCases: XCTestCase { + var asyncRequestManager: AsyncRequestManager! + var configurableSession: ConfigurableSessionMock! + var dummyURLRequest: URLRequest! + + override func setUp() { + super.setUp() + configurableSession = ConfigurableSessionMock() + asyncRequestManager = AsyncRequestManager(session: configurableSession) + dummyURLRequest = URLRequest(url: URL(validURL: "https://loremIpsum.com")) + } + + override func tearDown() { + super.tearDown() + asyncRequestManager = nil + configurableSession = nil + dummyURLRequest = nil + } + + // MARK: Vanilla request + + func testAsyncRequestSuccessfulConsumption() async throws { + // Given + let expectedData = "expectedData".data(using: .utf8)! + configurableSession.stubbedData = expectedData + + // When + let retrievedData = try await asyncRequestManager.request(dummyURLRequest) + + // Then + XCTAssertEqual(retrievedData, expectedData) + } + + func testAsyncRequestFailureWithoutHTTPResponse() async { + // Given + configurableSession.repliesWithHTTPResponse = false + + // When / Then + await assertThrowsNetworkError(.malformedRequest) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestFailureOnMissingData() async { + // Given + configurableSession.stubbedData = nil + + // When / Then + await assertThrowsNetworkError(.forbiddenResource) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 404 + + // When / Then + await assertThrowsNetworkError(.notFound) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + // MARK: Decodable request + + func testAsyncDecodableRequestSuccessfulConsumption() async throws { + // Given + configurableSession.stubbedData = #"{ "message": "hello world" }"#.data(using: .utf8) + + // When + let decodedReply: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) + + // Then + XCTAssertEqual(decodedReply, DummyDecodable(message: "hello world")) + } + + func testAsyncDecodableRequestFailureOnCorruptedReply() async { + // Given + configurableSession.stubbedData = "notAValidJSON".data(using: .utf8) + + // When + do { + let _: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) + XCTFail("A corrupted reply shouldn't decode successfully") + } catch { + // Then + XCTAssertTrue(error is DecodingError) + } + } + + // MARK: CRUD conveniences + + func testCreatePerformsPOSTAttachingPayload() async throws { + // Given + let payload = "newResource".data(using: .utf8)! + + // When + _ = try await asyncRequestManager.create(dummyURLRequest, payload: payload) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "POST") + XCTAssertEqual(configurableSession.lastRequest?.httpBody, payload) + } + + func testReadPerformsGET() async throws { + // When + _ = try await asyncRequestManager.read(dummyURLRequest) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "GET") + XCTAssertNil(configurableSession.lastRequest?.httpBody) + } + + func testUpdatePerformsPUTByDefault() async throws { + // Given + let payload = "amendedResource".data(using: .utf8)! + + // When + _ = try await asyncRequestManager.update(dummyURLRequest, payload: payload) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "PUT") + XCTAssertEqual(configurableSession.lastRequest?.httpBody, payload) + } + + func testUpdateSupportsPATCH() async throws { + // When + _ = try await asyncRequestManager.update(dummyURLRequest, method: .patch) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "PATCH") + } + + func testDeletePerformsDELETE() async throws { + // When + try await asyncRequestManager.delete(dummyURLRequest) + + // Then + XCTAssertEqual(configurableSession.lastRequest?.httpMethod, "DELETE") + } + + func testCRUDConveniencesPropagateFailures() async { + // Given + configurableSession.stubbedStatusCode = 500 + + // When / Then + await assertThrowsNetworkError(.serverDown) { + _ = try await self.asyncRequestManager.read(self.dummyURLRequest) + } + } + + // MARK: Upload + + func testUploadSuccessfulConsumption() async throws { + // Given + let payload = "avatarBytes".data(using: .utf8)! + let serverReply = "uploadAccepted".data(using: .utf8)! + configurableSession.stubbedData = serverReply + + // When + let retrievedReply = try await asyncRequestManager.upload(dummyURLRequest, from: payload) + + // Then + XCTAssertEqual(retrievedReply, serverReply) + XCTAssertEqual(configurableSession.lastUploadedPayload, payload) + } + + func testUploadFailureMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 413 + + // When / Then + await assertThrowsNetworkError(.payloadTooLarge) { + _ = try await self.asyncRequestManager.upload(self.dummyURLRequest, from: Data()) + } + } + + // MARK: Download + + func testDownloadSuccessfulConsumptionPersistsFile() async throws { + // Given + let expectedContent = "downloadedContent" + let temporaryDownload = try givenTemporaryFile(content: expectedContent) + configurableSession.stubbedDownloadLocation = temporaryDownload + + // When + let persistedFile = try await asyncRequestManager.download(dummyURLRequest) + + // Then + XCTAssertNotEqual(persistedFile, temporaryDownload) + XCTAssertEqual(try String(contentsOf: persistedFile, encoding: .utf8), expectedContent) + + try FileManager.default.removeItem(at: persistedFile) + } + + func testDownloadFailureWithoutHTTPResponse() async { + // Given + configurableSession.repliesWithHTTPResponse = false + + // When / Then + await assertThrowsNetworkError(.malformedRequest) { + _ = try await self.asyncRequestManager.download(self.dummyURLRequest) + } + } + + func testDownloadFailureOnMissingFile() async { + // Given + configurableSession.stubbedDownloadLocation = nil + + // When / Then + await assertThrowsNetworkError(.forbiddenResource) { + _ = try await self.asyncRequestManager.download(self.dummyURLRequest) + } + } + + func testDownloadFailureMapsHTTPErrorCodes() async { + // Given + configurableSession.stubbedStatusCode = 401 + + // When / Then + await assertThrowsNetworkError(.unauthorized) { + _ = try await self.asyncRequestManager.download(self.dummyURLRequest) + } + } +} + +private extension AsyncRequestManagerTestCases { + func givenTemporaryFile(content: String) throws -> URL { + let temporaryFile = FileManager.default.temporaryDirectory + .appendingPathComponent("MauriNetTestDownload-\(UUID().uuidString)") + .appendingPathExtension("txt") + try content.write(to: temporaryFile, atomically: true, encoding: .utf8) + + return temporaryFile + } + + func assertThrowsNetworkError(_ expectedError: NetworkError, + during operation: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line) async { + do { + try await operation() + XCTFail("Operation should've thrown \(expectedError)", file: file, line: line) + } catch let thrownError as NetworkError { + XCTAssertEqual(thrownError, expectedError, file: file, line: line) + } catch { + XCTFail("Unexpected error type thrown: \(error)", file: file, line: line) + } + } +} diff --git a/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift b/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift new file mode 100644 index 0000000..8b68bc5 --- /dev/null +++ b/Tests/MauriNetTests/Tests/HTTPMethodTestCases.swift @@ -0,0 +1,19 @@ +// +// HTTPMethodTestCases.swift +// +// +// Created by Mauricio Chirino on 03/07/26. +// + +import XCTest +@testable import MauriNet + +final class HTTPMethodTestCases: XCTestCase { + func testUppercasedRepresentations() { + XCTAssertEqual(HTTPMethod.get.value, "GET") + XCTAssertEqual(HTTPMethod.post.value, "POST") + XCTAssertEqual(HTTPMethod.put.value, "PUT") + XCTAssertEqual(HTTPMethod.patch.value, "PATCH") + XCTAssertEqual(HTTPMethod.delete.value, "DELETE") + } +} diff --git a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift index 042c8c9..90223b8 100644 --- a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift +++ b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift @@ -6,6 +6,9 @@ // import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import MauriNet final class MauriNetSessionTestCases: XCTestCase { diff --git a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift index c1a829e..5d5621d 100644 --- a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift +++ b/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift @@ -1,5 +1,8 @@ import XCTest +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif @testable import MauriNet final class MauriNetURLBuilderTestCases: XCTestCase { From c3575fc6c7dc4a9f44898c1c0b67850e70fc3f25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:08:43 +0000 Subject: [PATCH 2/3] Raise platform floor to n-2 and adopt native async URLSession Per the n-2 support policy (keep up to two versions behind current): - swift-tools 6.0 with Swift 6 language mode; floors raised to iOS 17 / macOS 14 / tvOS 17 / watchOS 10 - URLSessionable reshaped into async requirements (retrieveData, downloadFile, upload) forwarding to URLSession's native async APIs, replacing the hand-rolled continuation wrappers. Task cancellation now propagates to the underlying network request - Transport failures map through ResponseValidator.mapTransportFailure: URLError/CancellationError cancellations -> .cancelled, timeouts -> .timeout, anything else -> .unknown - Audited Sendable conformances across the public surface - Legacy RequestManager bridges its completion API through AsyncRequestManager instead of duplicating the session plumbing - Test doubles no longer subclass deprecated URLSessionTask types; new transport-failure mapping tests added (31 tests total) - README requirements and testability example updated Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017esYpGrJ3HtXUTaGiJ5jff --- Package.swift | 10 +- README.md | 18 ++- Sources/MauriNet/Combine/Agent.swift | 1 - .../Concurrency/AsyncRequestManager.swift | 59 ++++---- .../Concurrency/AsyncRequestable.swift | 2 +- Sources/MauriNet/Global/Constants.swift | 4 +- Sources/MauriNet/Global/HTTPMethods.swift | 2 +- Sources/MauriNet/Global/NetworkError.swift | 2 +- Sources/MauriNet/URLSession/APIEndpoint.swift | 2 +- .../MauriNet/URLSession/EndpointBuilder.swift | 2 +- .../MauriNet/URLSession/RequestManager.swift | 16 ++- .../URLSession/RequestableManager.swift | 59 +++----- .../URLSession/ResponseValidator.swift | 77 ++++------ .../TestDoubles/Mocks/SessionMock.swift | 132 ++++++++---------- .../Mocks/URLSessionDataTaskMock.swift | 50 ------- .../Tests/AsyncRequestManagerTestCases.swift | 58 +++++--- .../Tests/MauriNetSessionTestCases.swift | 6 +- 17 files changed, 222 insertions(+), 278 deletions(-) delete mode 100644 Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift diff --git a/Package.swift b/Package.swift index 2961b1b..bd09fa9 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,10 +6,10 @@ import PackageDescription let package = Package( name: "MauriNet", platforms: [ - .iOS(.v13), - .macOS(.v10_15), - .tvOS(.v13), - .watchOS(.v6) + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10) ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/README.md b/README.md index 2656166..a230c91 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Thin layer for standard network handling, with **async/await as a first class ci - ✅ Vanilla async/await wrappers for CRUD network operations (`create` / `read` / `update` / `delete`) - ✅ async/await download and upload APIs - ✅ Type-safe `Decodable` requests out of the box +- ✅ Cooperative cancellation: cancelling the surrounding `Task` cancels the network request +- ✅ Swift 6 language mode with audited `Sendable` conformances - ✅ Endpoint/URL assembling helpers - ✅ Legacy completion handler support (`RequestManager`) - ✅ 100% `URLSession` under the hood. Zero third-party dependencies @@ -16,8 +18,8 @@ Thin layer for standard network handling, with **async/await as a first class ci ## Requirements -- Swift 5.7+ -- iOS 13+ / macOS 10.15+ / tvOS 13+ / watchOS 6+ +- Swift 6.0+ (Xcode 16+) +- iOS 17+ / macOS 14+ / tvOS 17+ / watchOS 10+ ## Installation @@ -159,7 +161,17 @@ Both `AsyncRequestManager` and `RequestManager` are backed by the `URLSessionabl ```swift struct StubbedSession: URLSessionable { - // return canned data/download/upload tasks + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + // return canned data + response + } + + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + // return canned file location + response + } + + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + // return canned server reply + response + } } let testableManager = AsyncRequestManager(session: StubbedSession()) diff --git a/Sources/MauriNet/Combine/Agent.swift b/Sources/MauriNet/Combine/Agent.swift index 996a9f0..1e442dc 100644 --- a/Sources/MauriNet/Combine/Agent.swift +++ b/Sources/MauriNet/Combine/Agent.swift @@ -47,7 +47,6 @@ struct Agent { /// - request: request to be performed by the agent /// - decoder: type of decoding for request. Defaults to `JSONDecoder` instance /// - Returns: chainable response `AnyPublisher` object - @available(iOS 13.0, *) func run(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher, Error> { urlSession diff --git a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift index 6b06028..819a62e 100644 --- a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift +++ b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift @@ -12,16 +12,19 @@ import FoundationNetworking /// Wrapper object that handles network sessions through Swift concurrency (async/await) public struct AsyncRequestManager: AsyncRequestable { - private let session: URLSessionable + private let session: any URLSessionable /// Initializes the network session /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` - public init(session: URLSessionable = URLSession.shared) { + public init(session: any URLSessionable = URLSession.shared) { self.session = session } /// Triggers a generic request, suspending until the remote server replies /// + /// Cancelling the surrounding `Task` cancels the underlying network request as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// /// Example of usage: /// /// let endpointRequest = URLRequest(url: URL(validURL: "https://github.com")) @@ -32,10 +35,13 @@ public struct AsyncRequestManager: AsyncRequestable { /// - Returns: raw bytes retrieved from the remote server /// - Throws: `NetworkError` describing what went wrong with the request public func request(_ request: URLRequest) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - session.dataTaskWithURL(request) { data, response, error in - continuation.resume(with: ResponseValidator.validate(data: data, response: response, error: error)) - }.resume() + do { + let (retrievedData, response) = try await session.retrieveData(for: request) + try ResponseValidator.validate(response) + + return retrievedData + } catch { + throw ResponseValidator.mapTransportFailure(error) } } @@ -67,6 +73,9 @@ public struct AsyncRequestManager: AsyncRequestable { /// directory, so it remains available after this method returns. Move it to a permanent location /// (or consume it) at your earliest convenience since the system may eventually purge temporary files. /// + /// Cancelling the surrounding `Task` cancels the underlying download as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// /// Example of usage: /// /// let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) @@ -77,27 +86,22 @@ public struct AsyncRequestManager: AsyncRequestable { /// - Returns: local file `URL` where the downloaded resource was stored /// - Throws: `NetworkError` describing what went wrong with the request public func download(_ request: URLRequest) async throws -> URL { - try await withCheckedThrowingContinuation { continuation in - session.downloadTaskWithURL(request) { location, response, error in - let validation = ResponseValidator.validate(location: location, response: response, error: error) - - switch validation { - case .success(let temporaryLocation): - // The temporary file only lives until this completion returns; relocate it so callers can use it - do { - continuation.resume(returning: try persistedCopy(of: temporaryLocation)) - } catch { - continuation.resume(throwing: error) - } - case .failure(let networkError): - continuation.resume(throwing: networkError) - } - }.resume() + do { + let (temporaryLocation, response) = try await session.downloadFile(for: request) + try ResponseValidator.validate(response) + + // The temporary file only lives until the session reclaims it; relocate it so callers can use it + return try persistedCopy(of: temporaryLocation) + } catch { + throw ResponseValidator.mapTransportFailure(error) } } /// Uploads the given payload as the request's body, suspending until the remote server replies /// + /// Cancelling the surrounding `Task` cancels the underlying upload as well, + /// surfacing as a thrown `NetworkError.cancelled`. + /// /// Example of usage: /// /// var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) @@ -111,10 +115,13 @@ public struct AsyncRequestManager: AsyncRequestable { /// - Returns: raw bytes from the remote server's reply /// - Throws: `NetworkError` describing what went wrong with the request public func upload(_ request: URLRequest, from payload: Data) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - session.uploadTaskWithURL(request, from: payload) { data, response, error in - continuation.resume(with: ResponseValidator.validate(data: data, response: response, error: error)) - }.resume() + do { + let (serverReply, response) = try await session.upload(payload, for: request) + try ResponseValidator.validate(response) + + return serverReply + } catch { + throw ResponseValidator.mapTransportFailure(error) } } diff --git a/Sources/MauriNet/Concurrency/AsyncRequestable.swift b/Sources/MauriNet/Concurrency/AsyncRequestable.swift index 04876ab..299d64f 100644 --- a/Sources/MauriNet/Concurrency/AsyncRequestable.swift +++ b/Sources/MauriNet/Concurrency/AsyncRequestable.swift @@ -11,7 +11,7 @@ import FoundationNetworking #endif /// Network manager type for Swift concurrency (async/await) -public protocol AsyncRequestable { +public protocol AsyncRequestable: Sendable { /// Triggers a generic request, suspending until the remote server replies /// - Parameter request: URL request specifications /// - Returns: raw bytes retrieved from the remote server diff --git a/Sources/MauriNet/Global/Constants.swift b/Sources/MauriNet/Global/Constants.swift index 448b43c..a377cb3 100644 --- a/Sources/MauriNet/Global/Constants.swift +++ b/Sources/MauriNet/Global/Constants.swift @@ -14,13 +14,13 @@ import FoundationNetworking public typealias URLBuilderResult = Result /// Custom error type for `APIEndpoint` built request -public enum URLBuilderError: Error { +public enum URLBuilderError: Error, Sendable { /// The endpoint was not assembled properly case poorAssembling } /// Types of HTTP scheme -public enum APIScheme: String { +public enum APIScheme: String, Sendable { /// secure scheme case safe = "https" diff --git a/Sources/MauriNet/Global/HTTPMethods.swift b/Sources/MauriNet/Global/HTTPMethods.swift index 73c974f..a595dd3 100644 --- a/Sources/MauriNet/Global/HTTPMethods.swift +++ b/Sources/MauriNet/Global/HTTPMethods.swift @@ -8,7 +8,7 @@ import Foundation /// HTTP methods supported -public enum HTTPMethod { +public enum HTTPMethod: Sendable { /// Representation of [GET](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/GET) method case get diff --git a/Sources/MauriNet/Global/NetworkError.swift b/Sources/MauriNet/Global/NetworkError.swift index f93f8f3..b85d09c 100644 --- a/Sources/MauriNet/Global/NetworkError.swift +++ b/Sources/MauriNet/Global/NetworkError.swift @@ -8,7 +8,7 @@ import Foundation /// Type of network error codes supported -public enum NetworkError: Int, Error, CaseIterable { +public enum NetworkError: Int, Error, CaseIterable, Sendable { /// The request was cancelled case cancelled = -999 diff --git a/Sources/MauriNet/URLSession/APIEndpoint.swift b/Sources/MauriNet/URLSession/APIEndpoint.swift index f432757..7c78b58 100644 --- a/Sources/MauriNet/URLSession/APIEndpoint.swift +++ b/Sources/MauriNet/URLSession/APIEndpoint.swift @@ -11,7 +11,7 @@ import FoundationNetworking #endif /// Helper object that centralize all required parameters for an endpoint -public struct APIEndpoint { +public struct APIEndpoint: Sendable { /// root URL. I.e. _github.com_, _developer.apple.com_ and so forth. Ending slash (`/`) is not required; in case a path is provided it'll be automatically added for those cases. public let host: String diff --git a/Sources/MauriNet/URLSession/EndpointBuilder.swift b/Sources/MauriNet/URLSession/EndpointBuilder.swift index 29a6668..5251839 100644 --- a/Sources/MauriNet/URLSession/EndpointBuilder.swift +++ b/Sources/MauriNet/URLSession/EndpointBuilder.swift @@ -11,7 +11,7 @@ import FoundationNetworking #endif /// Convenience endpoint builder handler -public struct EndpointBuilder { +public struct EndpointBuilder: Sendable { private let endpointSetup: APIEndpoint /// Instantiate the builder for the provided setup diff --git a/Sources/MauriNet/URLSession/RequestManager.swift b/Sources/MauriNet/URLSession/RequestManager.swift index 601653f..b228dbf 100644 --- a/Sources/MauriNet/URLSession/RequestManager.swift +++ b/Sources/MauriNet/URLSession/RequestManager.swift @@ -12,11 +12,11 @@ import FoundationNetworking /// Wrapper object that handles network session public struct RequestManager: RequestableManager { - private let session: URLSessionable + private let session: any URLSessionable /// Initializes the network session /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` - public init(session: URLSessionable = URLSession.shared) { + public init(session: any URLSessionable = URLSession.shared) { self.session = session } @@ -38,8 +38,14 @@ public struct RequestManager: RequestableManager { /// - request: URL request specifications /// - completion: Resulting completion closure from request public func request(_ request: URLRequest, completion: @escaping NetworkResult) { - session.dataTaskWithURL(request) { data, response, error in - completion(ResponseValidator.validate(data: data, response: response, error: error)) - }.resume() + let asyncManager = AsyncRequestManager(session: session) + + Task { + do { + completion(.success(try await asyncManager.request(request))) + } catch { + completion(.failure(ResponseValidator.mapTransportFailure(error))) + } + } } } diff --git a/Sources/MauriNet/URLSession/RequestableManager.swift b/Sources/MauriNet/URLSession/RequestableManager.swift index eb2787b..c6b3ebd 100644 --- a/Sources/MauriNet/URLSession/RequestableManager.swift +++ b/Sources/MauriNet/URLSession/RequestableManager.swift @@ -11,55 +11,40 @@ import FoundationNetworking #endif /// Custom `Result` object for type of networking result -public typealias NetworkResult = (Result) -> Void - -/// Wrapper for encapsulating Foundation native response values from Data task -public typealias DataTaskResult = @Sendable (Data?, URLResponse?, Error?) -> Void - -/// Wrapper for encapsulating Foundation native response values from Download task -public typealias DownloadTaskResult = @Sendable (URL?, URLResponse?, Error?) -> Void +public typealias NetworkResult = @Sendable (Result) -> Void extension URLSession: URLSessionable { - public func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - dataTask(with: request, completionHandler: completion) + public func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + try await data(for: request) } - public func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { - downloadTask(with: request, completionHandler: completion) + public func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + try await download(for: request) } - public func uploadTaskWithURL(_ request: URLRequest, - from payload: Data, - completion: @escaping DataTaskResult) -> URLSessionUploadTask { - uploadTask(with: request, from: payload, completionHandler: completion) + public func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + try await upload(for: request, from: payload) } } /// Network session type -public protocol URLSessionable { - /// Creates a data task honoring the whole `URLRequest` specification (HTTP method, headers, body and timeout) - /// - Parameters: - /// - request: URL request specifications - /// - completion: Foundation's native data task response values - /// - Returns: the assembled session data task. Remember invoking `resume()` on it to kick off the request - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask - - /// Creates a download task that stores the retrieved resource in a temporary file +public protocol URLSessionable: Sendable { + /// Retrieves the contents for the given request, honoring its whole specification (HTTP method, headers, body and timeout) + /// - Parameter request: URL request specifications + /// - Returns: raw bytes retrieved from the remote server alongside its response metadata + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) + + /// Downloads the resource at the given request into a temporary file + /// - Parameter request: URL request specifications + /// - Returns: temporary file location where the resource was stored alongside its response metadata + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) + + /// Uploads the given payload as the request's body /// - Parameters: - /// - request: URL request specifications - /// - completion: Foundation's native download task response values - /// - Returns: the assembled session download task. Remember invoking `resume()` on it to kick off the request - func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask - - /// Creates an upload task that sends the given payload as the request's body - /// - Parameters: - /// - request: URL request specifications /// - payload: raw bytes to be uploaded to the remote server - /// - completion: Foundation's native data task response values - /// - Returns: the assembled session upload task. Remember invoking `resume()` on it to kick off the request - func uploadTaskWithURL(_ request: URLRequest, - from payload: Data, - completion: @escaping DataTaskResult) -> URLSessionUploadTask + /// - request: URL request specifications + /// - Returns: raw bytes from the remote server's reply alongside its response metadata + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) } /// Network manager type diff --git a/Sources/MauriNet/URLSession/ResponseValidator.swift b/Sources/MauriNet/URLSession/ResponseValidator.swift index a8b031e..8aaa650 100644 --- a/Sources/MauriNet/URLSession/ResponseValidator.swift +++ b/Sources/MauriNet/URLSession/ResponseValidator.swift @@ -15,61 +15,38 @@ import FoundationNetworking enum ResponseValidator { static let httpResponseCodeSuccessRange = (200..<300) - /// Validates a raw data task response, mapping it into the library's `Result` type - /// - Parameters: - /// - data: raw bytes retrieved from the remote server, if any - /// - response: HTTP response metadata, if any - /// - error: transport error surfaced by `URLSession`, if any - /// - Returns: either the retrieved `Data` or the mapped `NetworkError` - static func validate(data: Data?, response: URLResponse?, error: Error?) -> Result { - switch validateResponse(response, error: error) { - case .success(let httpResponse): - guard let retrievedData = data else { - debugPrint("Unable to parse data due to \(String(describing: error?.localizedDescription))") - return .failure(.forbiddenResource) - } - - if httpResponseCodeSuccessRange.contains(httpResponse.statusCode) { - return .success(retrievedData) - } else { - return .failure(NetworkError.buildError(from: httpResponse.statusCode)) - } - case .failure(let networkError): - return .failure(networkError) + /// Validates the HTTP metadata attached to a network reply + /// - Parameter response: response metadata coming from the network session + /// - Throws: `NetworkError.malformedRequest` for non-HTTP replies or the mapped + /// `NetworkError` when the reply's status code falls outside the success range + static func validate(_ response: URLResponse) throws { + guard let httpResponse = response as? HTTPURLResponse else { + debugPrint("Unable to parse non-HTTP response: \(response)") + throw NetworkError.malformedRequest } - } - - /// Validates a raw download task response, mapping it into the library's `Result` type - /// - Parameters: - /// - location: temporary file location where `URLSession` stored the downloaded resource, if any - /// - response: HTTP response metadata, if any - /// - error: transport error surfaced by `URLSession`, if any - /// - Returns: either the temporary file `URL` or the mapped `NetworkError` - static func validate(location: URL?, response: URLResponse?, error: Error?) -> Result { - switch validateResponse(response, error: error) { - case .success(let httpResponse): - // Unlike data tasks, an HTTP error here never yields a usable file: map the status code first - guard httpResponseCodeSuccessRange.contains(httpResponse.statusCode) else { - return .failure(NetworkError.buildError(from: httpResponse.statusCode)) - } - guard let retrievedLocation = location else { - debugPrint("Unable to retrieve file due to \(String(describing: error?.localizedDescription))") - return .failure(.forbiddenResource) - } - - return .success(retrievedLocation) - case .failure(let networkError): - return .failure(networkError) + guard httpResponseCodeSuccessRange.contains(httpResponse.statusCode) else { + throw NetworkError.buildError(from: httpResponse.statusCode) } } - private static func validateResponse(_ response: URLResponse?, error: Error?) -> Result { - guard let httpResponse = response as? HTTPURLResponse else { - debugPrint("Unable to parse response due to \(String(describing: error?.localizedDescription))") - return .failure(.malformedRequest) + /// Maps transport-layer failures into the library's `NetworkError` domain + /// - Parameter error: error thrown by the underlying network session + /// - Returns: matching `NetworkError`. Already-mapped errors pass through untouched; + /// cancellations and timeouts keep their specific case and anything else falls back to `.unknown` + static func mapTransportFailure(_ error: Error) -> NetworkError { + switch error { + case let alreadyMapped as NetworkError: + return alreadyMapped + case is CancellationError: + return .cancelled + case let urlError as URLError where urlError.code == .cancelled: + return .cancelled + case let urlError as URLError where urlError.code == .timedOut: + return .timeout + default: + debugPrint("Unhandled transport failure: \(error.localizedDescription)") + return .unknown } - - return .success(httpResponse) } } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift index cdb6152..a35d669 100644 --- a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift +++ b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift @@ -1,5 +1,5 @@ // -// URLFailingSessionMock.swift +// SessionMock.swift // // // Created by Mauricio Chirino on 29/11/20. @@ -11,127 +11,113 @@ import FoundationNetworking #endif @testable import MauriNet -/// URL session mocking implementation during testing. -/// [Source](https://masilotti.com/testing-nsurlsession-input/) +/// Session double whose replies never carry an HTTP response, mimicking transport-level failures struct URLFailingSessionMock: URLSessionable { - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - return URLSessionDataTaskMock { - completion(nil, nil, nil) - } + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + (Data(), plainResponse(for: request)) } - func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { - return URLSessionDownloadTaskMock { - completion(nil, nil, nil) - } + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + (FileManager.default.temporaryDirectory, plainResponse(for: request)) } - func uploadTaskWithURL(_ request: URLRequest, - from payload: Data, - completion: @escaping DataTaskResult) -> URLSessionUploadTask { - return URLSessionUploadTaskMock { - completion(nil, nil, nil) - } + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + (Data(), plainResponse(for: request)) + } + + private func plainResponse(for request: URLRequest) -> URLResponse { + URLResponse(url: request.url!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) } } +/// Session double that always replies successfully with dummy content struct URLSuccessSessionMock: URLSessionable { - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { - return URLSessionDataTaskMock { - let dummyData = "dummyData".data(using: .utf8)! - let response = HTTPURLResponse(url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - - completion(dummyData, response, nil) - } + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { + ("dummyData".data(using: .utf8)!, successResponse(for: request)) } - func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { - return URLSessionDownloadTaskMock { - let response = HTTPURLResponse(url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil) - - completion(FileManager.default.temporaryDirectory, response, nil) - } + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { + (FileManager.default.temporaryDirectory, successResponse(for: request)) } - func uploadTaskWithURL(_ request: URLRequest, - from payload: Data, - completion: @escaping DataTaskResult) -> URLSessionUploadTask { - return URLSessionUploadTaskMock { - let response = HTTPURLResponse(url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil) + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { + (payload, successResponse(for: request)) + } - completion(payload, response, nil) - } + private func successResponse(for request: URLRequest) -> URLResponse { + HTTPURLResponse(url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: nil)! } } /// Fully configurable session mock: stubs replies per task flavor and records /// the last request/payload that went through it for later assertion -final class ConfigurableSessionMock: URLSessionable { - /// Data returned on data/upload task completions. Defaults to a non empty dummy - var stubbedData: Data? = "dummyData".data(using: .utf8) +final class ConfigurableSessionMock: URLSessionable, @unchecked Sendable { + /// Data returned on data/upload replies. Defaults to a non empty dummy + var stubbedData: Data = "dummyData".data(using: .utf8)! /// HTTP status code stamped on the stubbed response. Defaults to `200` var stubbedStatusCode: Int = 200 - /// When `false`, completions reply with a `nil` response (as in transport failures). Defaults to `true` + /// When `false`, replies carry a plain (non-HTTP) response, as in transport failures. Defaults to `true` var repliesWithHTTPResponse = true - /// Temporary file location returned on download task completions. Defaults to `nil` - var stubbedDownloadLocation: URL? + /// When set, every session method throws this error instead of replying. Defaults to `nil` + var stubbedTransportError: Error? - /// Last request that went through any of the session's tasks + /// Temporary file location returned on download replies. Defaults to the temporary directory itself + var stubbedDownloadLocation: URL = FileManager.default.temporaryDirectory + + /// Last request that went through the session private(set) var lastRequest: URLRequest? - /// Last payload that went through the session's upload task + /// Last payload that went through the session's upload private(set) var lastUploadedPayload: Data? - func dataTaskWithURL(_ request: URLRequest, completion: @escaping DataTaskResult) -> URLSessionDataTask { + func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { lastRequest = request + try throwIfStubbed() - return URLSessionDataTaskMock { [weak self] in - guard let self = self else { return } - completion(self.stubbedData, self.assembleResponse(for: request), nil) - } + return (stubbedData, assembleResponse(for: request)) } - func downloadTaskWithURL(_ request: URLRequest, completion: @escaping DownloadTaskResult) -> URLSessionDownloadTask { + func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { lastRequest = request + try throwIfStubbed() - return URLSessionDownloadTaskMock { [weak self] in - guard let self = self else { return } - completion(self.stubbedDownloadLocation, self.assembleResponse(for: request), nil) - } + return (stubbedDownloadLocation, assembleResponse(for: request)) } - func uploadTaskWithURL(_ request: URLRequest, - from payload: Data, - completion: @escaping DataTaskResult) -> URLSessionUploadTask { + func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { lastRequest = request lastUploadedPayload = payload + try throwIfStubbed() + + return (stubbedData, assembleResponse(for: request)) + } - return URLSessionUploadTaskMock { [weak self] in - guard let self = self else { return } - completion(self.stubbedData, self.assembleResponse(for: request), nil) + private func throwIfStubbed() throws { + if let stubbedTransportError = stubbedTransportError { + throw stubbedTransportError } } - private func assembleResponse(for request: URLRequest) -> URLResponse? { + private func assembleResponse(for request: URLRequest) -> URLResponse { guard repliesWithHTTPResponse else { - return nil + return URLResponse(url: request.url!, + mimeType: nil, + expectedContentLength: 0, + textEncodingName: nil) } return HTTPURLResponse(url: request.url!, statusCode: stubbedStatusCode, httpVersion: nil, - headerFields: nil) + headerFields: nil)! } } diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift deleted file mode 100644 index 51bca51..0000000 --- a/Tests/MauriNetTests/TestDoubles/Mocks/URLSessionDataTaskMock.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// URLSessionDataTaskMock.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -final class URLSessionDataTaskMock: URLSessionDataTask { - private let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - /// Fulfills the provided closure immediately - override func resume() { - closure() - } -} - -final class URLSessionDownloadTaskMock: URLSessionDownloadTask { - private let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - /// Fulfills the provided closure immediately - override func resume() { - closure() - } -} - -final class URLSessionUploadTaskMock: URLSessionUploadTask { - private let closure: () -> Void - - init(closure: @escaping () -> Void) { - self.closure = closure - } - - /// Fulfills the provided closure immediately - override func resume() { - closure() - } -} diff --git a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift index 47d9a3a..45ac613 100644 --- a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift +++ b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift @@ -54,22 +54,54 @@ final class AsyncRequestManagerTestCases: XCTestCase { } } - func testAsyncRequestFailureOnMissingData() async { + func testAsyncRequestMapsHTTPErrorCodes() async { // Given - configurableSession.stubbedData = nil + configurableSession.stubbedStatusCode = 404 // When / Then - await assertThrowsNetworkError(.forbiddenResource) { + await assertThrowsNetworkError(.notFound) { _ = try await self.asyncRequestManager.request(self.dummyURLRequest) } } - func testAsyncRequestMapsHTTPErrorCodes() async { + // MARK: Transport failure mapping + + func testAsyncRequestMapsCancellation() async { // Given - configurableSession.stubbedStatusCode = 404 + configurableSession.stubbedTransportError = URLError(.cancelled) // When / Then - await assertThrowsNetworkError(.notFound) { + await assertThrowsNetworkError(.cancelled) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsSwiftConcurrencyCancellation() async { + // Given + configurableSession.stubbedTransportError = CancellationError() + + // When / Then + await assertThrowsNetworkError(.cancelled) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsTimeouts() async { + // Given + configurableSession.stubbedTransportError = URLError(.timedOut) + + // When / Then + await assertThrowsNetworkError(.timeout) { + _ = try await self.asyncRequestManager.request(self.dummyURLRequest) + } + } + + func testAsyncRequestMapsUnhandledTransportFailures() async { + // Given + configurableSession.stubbedTransportError = URLError(.notConnectedToInternet) + + // When / Then + await assertThrowsNetworkError(.unknown) { _ = try await self.asyncRequestManager.request(self.dummyURLRequest) } } @@ -78,7 +110,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testAsyncDecodableRequestSuccessfulConsumption() async throws { // Given - configurableSession.stubbedData = #"{ "message": "hello world" }"#.data(using: .utf8) + configurableSession.stubbedData = #"{ "message": "hello world" }"#.data(using: .utf8)! // When let decodedReply: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) @@ -89,7 +121,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testAsyncDecodableRequestFailureOnCorruptedReply() async { // Given - configurableSession.stubbedData = "notAValidJSON".data(using: .utf8) + configurableSession.stubbedData = "notAValidJSON".data(using: .utf8)! // When do { @@ -216,16 +248,6 @@ final class AsyncRequestManagerTestCases: XCTestCase { } } - func testDownloadFailureOnMissingFile() async { - // Given - configurableSession.stubbedDownloadLocation = nil - - // When / Then - await assertThrowsNetworkError(.forbiddenResource) { - _ = try await self.asyncRequestManager.download(self.dummyURLRequest) - } - } - func testDownloadFailureMapsHTTPErrorCodes() async { // Given configurableSession.stubbedStatusCode = 401 diff --git a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift index 90223b8..ebe9f06 100644 --- a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift +++ b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift @@ -1,6 +1,6 @@ // // MauriNetSessionTestCases.swift -// +// // // Created by Mauricio Chirino on 05/12/20. // @@ -41,7 +41,7 @@ final class MauriNetSessionTestCases: XCTestCase { } // Then - wait(for: [requestExpectation], timeout: 0.1) + wait(for: [requestExpectation], timeout: 1) } func testRequestManagerSuccessConsumptionHandling() { @@ -62,7 +62,7 @@ final class MauriNetSessionTestCases: XCTestCase { } // Then - wait(for: [requestExpectation], timeout: 0.1) + wait(for: [requestExpectation], timeout: 1) } } From 18fb5a9751e49489f8bd76784f540d0385b748b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 15:18:31 +0000 Subject: [PATCH 3/3] Address review: purge legacy API and improve testability - Remove the completion-handler API (RequestManager, RequestableManager, NetworkResult) and its tests: the library is now async-only - Remove APIEndpoint, EndpointBuilder, URLBuilderResult/URLBuilderError, APIScheme and their tests, plus the now-orphaned URLComponents/ Collection helpers and unused HTTPEncoding - Remove the internal WIP Combine Agent (drops the last Combine import) - Rename RequestableManager.swift to URLSessionable.swift to match its remaining content - download now accepts an explicit destination directory (download(_:into:)), defaulting to the temporary directory via a protocol extension, so the relocation target is injectable and assertable in tests - Replace force unwraps with XCTUnwrap in test payload setup - Purge downloaded artifacts via addTeardownBlock so cleanup runs regardless of test outcome - Document the uploaded payload variable in the upload example (doc comment and README); drop the legacy and endpoint-assembling README sections Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017esYpGrJ3HtXUTaGiJ5jff --- README.md | 42 ++---- Sources/MauriNet/Combine/Agent.swift | 66 --------- .../Concurrency/AsyncRequestManager.swift | 29 ++-- .../Concurrency/AsyncRequestable.swift | 19 ++- Sources/MauriNet/Global/Constants.swift | 39 ----- Sources/MauriNet/Global/Extensions.swift | 27 +--- Sources/MauriNet/Global/HTTPEncoding.swift | 20 --- Sources/MauriNet/URLSession/APIEndpoint.swift | 78 ---------- .../MauriNet/URLSession/EndpointBuilder.swift | 50 ------- .../MauriNet/URLSession/RequestManager.swift | 51 ------- ...ableManager.swift => URLSessionable.swift} | 14 +- .../TestDoubles/Mocks/SessionMock.swift | 46 +----- .../Tests/AsyncRequestManagerTestCases.swift | 42 ++++-- .../Tests/MauriNetSessionTestCases.swift | 81 ----------- .../Tests/MauriNetURLBuilderTestCases.swift | 135 ------------------ 15 files changed, 82 insertions(+), 657 deletions(-) delete mode 100644 Sources/MauriNet/Combine/Agent.swift delete mode 100644 Sources/MauriNet/Global/Constants.swift delete mode 100644 Sources/MauriNet/Global/HTTPEncoding.swift delete mode 100644 Sources/MauriNet/URLSession/APIEndpoint.swift delete mode 100644 Sources/MauriNet/URLSession/EndpointBuilder.swift delete mode 100644 Sources/MauriNet/URLSession/RequestManager.swift rename Sources/MauriNet/URLSession/{RequestableManager.swift => URLSessionable.swift} (77%) delete mode 100644 Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift delete mode 100644 Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift diff --git a/README.md b/README.md index a230c91..baf782b 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,6 @@ Thin layer for standard network handling, with **async/await as a first class ci - ✅ Type-safe `Decodable` requests out of the box - ✅ Cooperative cancellation: cancelling the surrounding `Task` cancels the network request - ✅ Swift 6 language mode with audited `Sendable` conformances -- ✅ Endpoint/URL assembling helpers -- ✅ Legacy completion handler support (`RequestManager`) - ✅ 100% `URLSession` under the hood. Zero third-party dependencies - ✅ Fully unit tested @@ -116,48 +114,28 @@ let localFile = try await manager.download(resourceRequest) // permanent location (or consume it) at your earliest convenience ``` -### Uploads +The destination can also be made explicit by providing an existing directory: ```swift -var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) -uploadRequest.httpMethod = HTTPMethod.post.value - -let serverReply = try await manager.upload(uploadRequest, from: avatarData) +let downloadsFolder = URL.documentsDirectory.appending(path: "Downloads") +let localFile = try await manager.download(resourceRequest, into: downloadsFolder) ``` -### Assembling endpoints - -`APIEndpoint` and `EndpointBuilder` remove the string concatenation boilerplate when building requests: +### Uploads ```swift -let githubEndpoint = APIEndpoint(host: "api.github.com") -let builder = EndpointBuilder(endpointSetup: githubEndpoint) - -// https://api.github.com/events?page=2 -let eventsRequest = builder.assembleRequest(path: "events", queryParameters: ["page": "2"]) -let events: [Event] = try await AsyncRequestManager().request(eventsRequest) -``` - -### Legacy completion handlers +var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) +uploadRequest.httpMethod = HTTPMethod.post.value -The original closure-based API remains available for codebases that haven't migrated to Swift concurrency yet: +// the data to be uploaded +let avatarData = try Data(contentsOf: avatarFileURL) -```swift -let sessionManager = RequestManager() - -sessionManager.request(eventsRequest) { result in - switch result { - case .success(let data): - // do whatever you wish with the retrieved data - case .failure(let error): - // handle the strongly typed NetworkError - } -} +let serverReply = try await manager.upload(uploadRequest, from: avatarData) ``` ### Testability -Both `AsyncRequestManager` and `RequestManager` are backed by the `URLSessionable` protocol (which `URLSession` already conforms to). Inject your own conforming double to unit test your networking layer without hitting the actual network: +`AsyncRequestManager` is backed by the `URLSessionable` protocol (which `URLSession` already conforms to). Inject your own conforming double to unit test your networking layer without hitting the actual network: ```swift struct StubbedSession: URLSessionable { diff --git a/Sources/MauriNet/Combine/Agent.swift b/Sources/MauriNet/Combine/Agent.swift deleted file mode 100644 index 1e442dc..0000000 --- a/Sources/MauriNet/Combine/Agent.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// Agent.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -#if canImport(Combine) -import Combine -import Foundation - -/// Response object resulting from network request -struct Response { - /// Generic value type from request's response - let value: T - - /// Useful for logging purposes and HTTP low level debugging - let response: URLResponse -} - -/// Promise-based agent for network requests executions. -/// [Inspiration post](https://www.vadimbulavin.com/modern-networking-in-swift-5-with-urlsession-combine-framework-and-codable/) -struct Agent { - private let urlSession: URLSession - - /// Initializes an agent for a `URLSession` - /// - Parameter urlSession: session where agent will live. Defaults to `.shared` - init(urlSession: URLSession = .shared) { - self.urlSession = urlSession - } - - /// Runs the agent for given request - /// - /// Example of usage: - /// - /// func executeAgentFor(urlString: String) -> AnyPublisher { - /// let agent = Agent() - /// let request = URLRequest(url: urlString) - /// - /// return agent.run(request) - /// .map(\.value) - /// .eraseToAnyPublisher() - /// } - /// - /// - /// - Parameters: - /// - request: request to be performed by the agent - /// - decoder: type of decoding for request. Defaults to `JSONDecoder` instance - /// - Returns: chainable response `AnyPublisher` object - func run(_ request: URLRequest, - _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher, Error> { - urlSession - .dataTaskPublisher(for: request) - .tryMap { result -> Response in - let value = try decoder.decode(T.self, from: result.data) - - return Response(value: value, response: result.response) - } - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} - -// Add a more general agent that only returns data and that doesn't assume Main thread handling by default. -// Check here https://heckj.github.io/swiftui-notes/ -#endif diff --git a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift index 819a62e..b105b87 100644 --- a/Sources/MauriNet/Concurrency/AsyncRequestManager.swift +++ b/Sources/MauriNet/Concurrency/AsyncRequestManager.swift @@ -69,9 +69,10 @@ public struct AsyncRequestManager: AsyncRequestable { /// Downloads the resource at the given request, suspending until it's fully retrieved /// - /// The downloaded file is moved out of `URLSession`'s ephemeral storage into the app's temporary - /// directory, so it remains available after this method returns. Move it to a permanent location - /// (or consume it) at your earliest convenience since the system may eventually purge temporary files. + /// The downloaded file is moved out of `URLSession`'s ephemeral storage into the given directory + /// (under a unique name preserving the original extension), so it remains available after this + /// method returns. When targeting the temporary directory, move the file to a permanent location + /// (or consume it) at your earliest convenience since the system may eventually purge it. /// /// Cancelling the surrounding `Task` cancels the underlying download as well, /// surfacing as a thrown `NetworkError.cancelled`. @@ -79,19 +80,22 @@ public struct AsyncRequestManager: AsyncRequestable { /// Example of usage: /// /// let resourceRequest = URLRequest(url: URL(validURL: "https://example.com/resource.zip")) + /// let downloadsFolder = URL.documentsDirectory.appending(path: "Downloads") /// let sessionManager = AsyncRequestManager() - /// let localFile = try await sessionManager.download(resourceRequest) + /// let localFile = try await sessionManager.download(resourceRequest, into: downloadsFolder) /// - /// - Parameter request: URL request specifications + /// - Parameters: + /// - request: URL request specifications + /// - directory: local directory where the downloaded resource will be stored. It must already exist /// - Returns: local file `URL` where the downloaded resource was stored /// - Throws: `NetworkError` describing what went wrong with the request - public func download(_ request: URLRequest) async throws -> URL { + public func download(_ request: URLRequest, into directory: URL) async throws -> URL { do { let (temporaryLocation, response) = try await session.downloadFile(for: request) try ResponseValidator.validate(response) // The temporary file only lives until the session reclaims it; relocate it so callers can use it - return try persistedCopy(of: temporaryLocation) + return try persistedCopy(of: temporaryLocation, into: directory) } catch { throw ResponseValidator.mapTransportFailure(error) } @@ -106,6 +110,10 @@ public struct AsyncRequestManager: AsyncRequestable { /// /// var uploadRequest = URLRequest(url: URL(validURL: "https://example.com/avatar")) /// uploadRequest.httpMethod = HTTPMethod.post.value + /// + /// // the data to be uploaded + /// let avatarData: Data = try Data(contentsOf: avatarFileURL) + /// /// let sessionManager = AsyncRequestManager() /// let serverReply = try await sessionManager.upload(uploadRequest, from: avatarData) /// @@ -125,13 +133,12 @@ public struct AsyncRequestManager: AsyncRequestable { } } - private func persistedCopy(of temporaryLocation: URL) throws -> URL { - let fileManager = FileManager.default - let enduringLocation = fileManager.temporaryDirectory + private func persistedCopy(of temporaryLocation: URL, into directory: URL) throws -> URL { + let enduringLocation = directory .appendingPathComponent(UUID().uuidString) .appendingPathExtension(temporaryLocation.pathExtension) - try fileManager.moveItem(at: temporaryLocation, to: enduringLocation) + try FileManager.default.moveItem(at: temporaryLocation, to: enduringLocation) return enduringLocation } diff --git a/Sources/MauriNet/Concurrency/AsyncRequestable.swift b/Sources/MauriNet/Concurrency/AsyncRequestable.swift index 299d64f..143022d 100644 --- a/Sources/MauriNet/Concurrency/AsyncRequestable.swift +++ b/Sources/MauriNet/Concurrency/AsyncRequestable.swift @@ -19,10 +19,12 @@ public protocol AsyncRequestable: Sendable { func request(_ request: URLRequest) async throws -> Data /// Downloads the resource at the given request, suspending until it's fully retrieved - /// - Parameter request: URL request specifications + /// - Parameters: + /// - request: URL request specifications + /// - directory: local directory where the downloaded resource will be stored /// - Returns: local file `URL` where the downloaded resource was stored /// - Throws: `NetworkError` describing what went wrong with the request - func download(_ request: URLRequest) async throws -> URL + func download(_ request: URLRequest, into directory: URL) async throws -> URL /// Uploads the given payload as the request's body, suspending until the remote server replies /// - Parameters: @@ -32,3 +34,16 @@ public protocol AsyncRequestable: Sendable { /// - Throws: `NetworkError` describing what went wrong with the request func upload(_ request: URLRequest, from payload: Data) async throws -> Data } + +public extension AsyncRequestable { + /// Downloads the resource at the given request into the app's temporary directory + /// + /// Convenience over `download(_:into:)`. Move the file to a permanent location (or consume it) + /// at your earliest convenience since the system may eventually purge temporary files. + /// - Parameter request: URL request specifications + /// - Returns: local file `URL` where the downloaded resource was stored + /// - Throws: `NetworkError` describing what went wrong with the request + func download(_ request: URLRequest) async throws -> URL { + try await download(request, into: FileManager.default.temporaryDirectory) + } +} diff --git a/Sources/MauriNet/Global/Constants.swift b/Sources/MauriNet/Global/Constants.swift deleted file mode 100644 index a377cb3..0000000 --- a/Sources/MauriNet/Global/Constants.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Constans.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Custom type for resulting object from `APIEndpoint` built request -public typealias URLBuilderResult = Result - -/// Custom error type for `APIEndpoint` built request -public enum URLBuilderError: Error, Sendable { - /// The endpoint was not assembled properly - case poorAssembling -} - -/// Types of HTTP scheme -public enum APIScheme: String, Sendable { - /// secure scheme - case safe = "https" - - /// insecure scheme - case unsafe = "http" - - /// scheme value appending _://_ - /// - /// Example of usage: - /// - /// APIScheme.safe.value - /// // returns -> https:// - var value: String { - return rawValue + "://" - } -} diff --git a/Sources/MauriNet/Global/Extensions.swift b/Sources/MauriNet/Global/Extensions.swift index 420514e..df36b75 100644 --- a/Sources/MauriNet/Global/Extensions.swift +++ b/Sources/MauriNet/Global/Extensions.swift @@ -1,6 +1,6 @@ // -// File.swift -// +// Extensions.swift +// // // Created by Mauricio Chirino on 05/12/20. // @@ -15,26 +15,3 @@ public extension URL { self.init(string: validURL)! } } - -public extension URLComponents { - /// Assumes the provided string contains a valid URL. Recommended usage with static assembled URLs; - /// for dynamic generated ones use failable init to avoid runtime crashes. - /// - Parameter validURL: A valid URL according to [RFC 1738](https://tools.ietf.org/html/rfc1738) - init(validURL: String) { - self.init(string: validURL)! - } -} - -/// Support for native `Collection` object formed by `URLQueryItem` items -public extension Collection where Element == URLQueryItem { - /// Returns -if available- the value for a parameter query within a URL - /// Example of usage: - /// - /// let tweetURLAvatar = "https://wwww.twitter.com/avatar.png?width=500&&height=500" - /// let components = URLComponent(validURL: tweetURLAvatar) - /// components.queryItems?["width"] // 500 - /// - subscript(_ name: String) -> String? { - first(where: { $0.name == name })?.value - } -} diff --git a/Sources/MauriNet/Global/HTTPEncoding.swift b/Sources/MauriNet/Global/HTTPEncoding.swift deleted file mode 100644 index 38fe0e8..0000000 --- a/Sources/MauriNet/Global/HTTPEncoding.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// HTTPEncoding.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation - -/// Types of HTTP supported encoding -enum HTTPEncoding { - /// url type (i.e. used in `GET` requests) - case url - - /// JSON type - case json - - /// body type - case body -} diff --git a/Sources/MauriNet/URLSession/APIEndpoint.swift b/Sources/MauriNet/URLSession/APIEndpoint.swift deleted file mode 100644 index 7c78b58..0000000 --- a/Sources/MauriNet/URLSession/APIEndpoint.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// APIEndpoint.swift -// -// -// Created by Mauricio Chirino on 29/11/20. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Helper object that centralize all required parameters for an endpoint -public struct APIEndpoint: Sendable { - /// root URL. I.e. _github.com_, _developer.apple.com_ and so forth. Ending slash (`/`) is not required; in case a path is provided it'll be automatically added for those cases. - public let host: String - - /// HTTP method for this endpoint - public let httpMethod: HTTPMethod - - /// endpoint http scheme that defines what kind of connection is - public let scheme: APIScheme - - /// timeout before the request expires - public let timeout: TimeInterval - - /// Creates an `APIEndpoint` with the set of given values - /// - Parameters: - /// - host: root URL. I.e. _github.com_, _developer.apple.com_ and so forth. Ending slash (`/`) is not required; in case a path is provided it'll be automatically added for those cases. - /// - httpMethod: type of HTTP method this endpoint will handle. Defaults to `.get` type - /// - scheme: whether or not this is a safe connection. Defaults to `.safe` type (https) - /// - timeout: timeout before the request expires. Defaults to `10` seconds - public init(host: String, - httpMethod: HTTPMethod = .get, - scheme: APIScheme = .safe, - timeout: TimeInterval = 10) { - self.host = host - self.httpMethod = httpMethod - self.scheme = scheme - self.timeout = timeout - } - - /// Builds a complete URL from the `host` value, given a path - /// - /// Example of usage: - /// - /// let githubEndpoint = APIEndpoint(host: "github.com") - /// githubEndpoint.buildRequest(for: "events") - /// // returns -> https://github.com/events - /// - /// - Parameter path: path (with or without query parameters) to append to the existing host - /// - Parameter queryDictionary: param query in the form of a dictionary to be assembled into the resulting URLRequest. Defaults to an empty one - /// - Returns: The assembled URL. - /// - public func buildRequest(for path: String, with queryDictionary: [String: String] = [:]) -> URLBuilderResult { - let assembledURL = scheme.value + host + "/\(path)" - var components = URLComponents(validURL: assembledURL) - - if !queryDictionary.isEmpty { - components.queryItems = buildQuery(basedOn: queryDictionary) - } - - if let builtURL = components.url { - var request = URLRequest(url: builtURL, timeoutInterval: timeout) - request.httpMethod = httpMethod.value - - return .success(request) - } else { - return .failure(.poorAssembling) - } - } - - private func buildQuery(basedOn dictionary: [String: String]) -> [URLQueryItem] { - return dictionary.reduce([URLQueryItem]()) { assembled, item in - assembled + [URLQueryItem(name: item.key, value: item.value)] - } - } -} diff --git a/Sources/MauriNet/URLSession/EndpointBuilder.swift b/Sources/MauriNet/URLSession/EndpointBuilder.swift deleted file mode 100644 index 5251839..0000000 --- a/Sources/MauriNet/URLSession/EndpointBuilder.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// EndpointBuilder.swift -// -// -// Created by Mauricio Chirino on 11/4/21. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Convenience endpoint builder handler -public struct EndpointBuilder: Sendable { - private let endpointSetup: APIEndpoint - - /// Instantiate the builder for the provided setup - /// - Parameter endpointSetup: root endpoint setup from where all builder setup will bootstrap - public init(endpointSetup: APIEndpoint) { - self.endpointSetup = endpointSetup - } - - /// Assembles an URL for the current host - /// - /// Example of usage: - /// - /// let githubEndpoint = APIEndpoint(host: "github.com") - /// let endpointBuilder = EndpointBuilder(endpointSetup: githubEndpoint) - /// endpointBuilder.assembleRequest(path: "events") { result in - /// switch(result) in - /// case success(let assembledRequest): - /// // do whatever you wish with URLRequest resulting type - /// case failure(let errorType): - /// // defaults to an URLRequest for google.com - /// } - /// - /// - Parameters: - /// - path: path for the desired endpoint - /// - queryParameters: URL's query parameters, should they exist. Defaults to an empty dictionary (no parameters) - /// - Returns: valid URL (defaults to [google.com](google.com) in case of error in assembly) - public func assembleRequest(path: String, queryParameters: [String: String] = [:]) -> URLRequest { - switch endpointSetup.buildRequest(for: path, with: queryParameters) { - case .success(let assembledRequest): - return assembledRequest - case .failure: - // TODO: this might be handled in a better way. - return URLRequest(url: URL.init(validURL: "google.com")) - } - } -} diff --git a/Sources/MauriNet/URLSession/RequestManager.swift b/Sources/MauriNet/URLSession/RequestManager.swift deleted file mode 100644 index b228dbf..0000000 --- a/Sources/MauriNet/URLSession/RequestManager.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// RequestManager.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import Foundation -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif - -/// Wrapper object that handles network session -public struct RequestManager: RequestableManager { - private let session: any URLSessionable - - /// Initializes the network session - /// - Parameter session: network session through which connection will be stablished with remote server. Defaults to Foundation `URLSession.shared` - public init(session: any URLSessionable = URLSession.shared) { - self.session = session - } - - /// Triggers a generic request in an asynchronous matter - /// - /// Example of usage: - /// - /// let endpointRequest = URLRequest(url: URL(validURL: "github.com")) - /// let sessionManager = RequestManager() - /// sessionManager.request(endpointRequest) { result in - /// switch(result) in - /// case success(let response): - /// // do whatever you wish with NetworkResult resulting type - /// case failure(let errorType): - /// // handle error appropriately - /// } - /// - /// - Parameters: - /// - request: URL request specifications - /// - completion: Resulting completion closure from request - public func request(_ request: URLRequest, completion: @escaping NetworkResult) { - let asyncManager = AsyncRequestManager(session: session) - - Task { - do { - completion(.success(try await asyncManager.request(request))) - } catch { - completion(.failure(ResponseValidator.mapTransportFailure(error))) - } - } - } -} diff --git a/Sources/MauriNet/URLSession/RequestableManager.swift b/Sources/MauriNet/URLSession/URLSessionable.swift similarity index 77% rename from Sources/MauriNet/URLSession/RequestableManager.swift rename to Sources/MauriNet/URLSession/URLSessionable.swift index c6b3ebd..f28bca6 100644 --- a/Sources/MauriNet/URLSession/RequestableManager.swift +++ b/Sources/MauriNet/URLSession/URLSessionable.swift @@ -1,5 +1,5 @@ // -// RequestableManager.swift +// URLSessionable.swift // // // Created by Mauricio Chirino on 07/12/20. @@ -10,9 +10,6 @@ import Foundation import FoundationNetworking #endif -/// Custom `Result` object for type of networking result -public typealias NetworkResult = @Sendable (Result) -> Void - extension URLSession: URLSessionable { public func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { try await data(for: request) @@ -46,12 +43,3 @@ public protocol URLSessionable: Sendable { /// - Returns: raw bytes from the remote server's reply alongside its response metadata func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) } - -/// Network manager type -public protocol RequestableManager { - /// Triggers a generic request in an asynchronous matter - /// - Parameters: - /// - request: URL request specifications - /// - completion: Resulting completion closure from request - func request(_ request: URLRequest, completion: @escaping NetworkResult) -} diff --git a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift index a35d669..c453633 100644 --- a/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift +++ b/Tests/MauriNetTests/TestDoubles/Mocks/SessionMock.swift @@ -11,55 +11,11 @@ import FoundationNetworking #endif @testable import MauriNet -/// Session double whose replies never carry an HTTP response, mimicking transport-level failures -struct URLFailingSessionMock: URLSessionable { - func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { - (Data(), plainResponse(for: request)) - } - - func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { - (FileManager.default.temporaryDirectory, plainResponse(for: request)) - } - - func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { - (Data(), plainResponse(for: request)) - } - - private func plainResponse(for request: URLRequest) -> URLResponse { - URLResponse(url: request.url!, - mimeType: nil, - expectedContentLength: 0, - textEncodingName: nil) - } -} - -/// Session double that always replies successfully with dummy content -struct URLSuccessSessionMock: URLSessionable { - func retrieveData(for request: URLRequest) async throws -> (Data, URLResponse) { - ("dummyData".data(using: .utf8)!, successResponse(for: request)) - } - - func downloadFile(for request: URLRequest) async throws -> (URL, URLResponse) { - (FileManager.default.temporaryDirectory, successResponse(for: request)) - } - - func upload(_ payload: Data, for request: URLRequest) async throws -> (Data, URLResponse) { - (payload, successResponse(for: request)) - } - - private func successResponse(for request: URLRequest) -> URLResponse { - HTTPURLResponse(url: request.url!, - statusCode: 200, - httpVersion: nil, - headerFields: nil)! - } -} - /// Fully configurable session mock: stubs replies per task flavor and records /// the last request/payload that went through it for later assertion final class ConfigurableSessionMock: URLSessionable, @unchecked Sendable { /// Data returned on data/upload replies. Defaults to a non empty dummy - var stubbedData: Data = "dummyData".data(using: .utf8)! + var stubbedData: Data = Data("dummyData".utf8) /// HTTP status code stamped on the stubbed response. Defaults to `200` var stubbedStatusCode: Int = 200 diff --git a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift index 45ac613..d93cf52 100644 --- a/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift +++ b/Tests/MauriNetTests/Tests/AsyncRequestManagerTestCases.swift @@ -34,7 +34,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testAsyncRequestSuccessfulConsumption() async throws { // Given - let expectedData = "expectedData".data(using: .utf8)! + let expectedData = try XCTUnwrap("expectedData".data(using: .utf8)) configurableSession.stubbedData = expectedData // When @@ -110,7 +110,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testAsyncDecodableRequestSuccessfulConsumption() async throws { // Given - configurableSession.stubbedData = #"{ "message": "hello world" }"#.data(using: .utf8)! + configurableSession.stubbedData = try XCTUnwrap(#"{ "message": "hello world" }"#.data(using: .utf8)) // When let decodedReply: DummyDecodable = try await asyncRequestManager.request(dummyURLRequest) @@ -119,9 +119,9 @@ final class AsyncRequestManagerTestCases: XCTestCase { XCTAssertEqual(decodedReply, DummyDecodable(message: "hello world")) } - func testAsyncDecodableRequestFailureOnCorruptedReply() async { + func testAsyncDecodableRequestFailureOnCorruptedReply() async throws { // Given - configurableSession.stubbedData = "notAValidJSON".data(using: .utf8)! + configurableSession.stubbedData = try XCTUnwrap("notAValidJSON".data(using: .utf8)) // When do { @@ -137,7 +137,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testCreatePerformsPOSTAttachingPayload() async throws { // Given - let payload = "newResource".data(using: .utf8)! + let payload = try XCTUnwrap("newResource".data(using: .utf8)) // When _ = try await asyncRequestManager.create(dummyURLRequest, payload: payload) @@ -158,7 +158,7 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testUpdatePerformsPUTByDefault() async throws { // Given - let payload = "amendedResource".data(using: .utf8)! + let payload = try XCTUnwrap("amendedResource".data(using: .utf8)) // When _ = try await asyncRequestManager.update(dummyURLRequest, payload: payload) @@ -198,8 +198,8 @@ final class AsyncRequestManagerTestCases: XCTestCase { func testUploadSuccessfulConsumption() async throws { // Given - let payload = "avatarBytes".data(using: .utf8)! - let serverReply = "uploadAccepted".data(using: .utf8)! + let payload = try XCTUnwrap("avatarBytes".data(using: .utf8)) + let serverReply = try XCTUnwrap("uploadAccepted".data(using: .utf8)) configurableSession.stubbedData = serverReply // When @@ -230,12 +230,28 @@ final class AsyncRequestManagerTestCases: XCTestCase { // When let persistedFile = try await asyncRequestManager.download(dummyURLRequest) + purgeAfterTest(persistedFile) // Then XCTAssertNotEqual(persistedFile, temporaryDownload) XCTAssertEqual(try String(contentsOf: persistedFile, encoding: .utf8), expectedContent) + } + + func testDownloadPersistsIntoProvidedDirectory() async throws { + // Given + let temporaryDownload = try givenTemporaryFile(content: "downloadedContent") + configurableSession.stubbedDownloadLocation = temporaryDownload + let customDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("MauriNetCustomDownloads-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: customDirectory, withIntermediateDirectories: true) + purgeAfterTest(customDirectory) + + // When + let persistedFile = try await asyncRequestManager.download(dummyURLRequest, into: customDirectory) - try FileManager.default.removeItem(at: persistedFile) + // Then + XCTAssertEqual(persistedFile.deletingLastPathComponent().standardizedFileURL.path, + customDirectory.standardizedFileURL.path) } func testDownloadFailureWithoutHTTPResponse() async { @@ -265,10 +281,18 @@ private extension AsyncRequestManagerTestCases { .appendingPathComponent("MauriNetTestDownload-\(UUID().uuidString)") .appendingPathExtension("txt") try content.write(to: temporaryFile, atomically: true, encoding: .utf8) + purgeAfterTest(temporaryFile) return temporaryFile } + /// Guarantees the given file is wiped once the current test finishes, regardless of its outcome + func purgeAfterTest(_ file: URL) { + addTeardownBlock { + try? FileManager.default.removeItem(at: file) + } + } + func assertThrowsNetworkError(_ expectedError: NetworkError, during operation: () async throws -> Void, file: StaticString = #filePath, diff --git a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift deleted file mode 100644 index ebe9f06..0000000 --- a/Tests/MauriNetTests/Tests/MauriNetSessionTestCases.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// MauriNetSessionTestCases.swift -// -// -// Created by Mauricio Chirino on 05/12/20. -// - -import XCTest -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -@testable import MauriNet - -final class MauriNetSessionTestCases: XCTestCase { - var mockRequestManager: RequestManager! - var dummyURLRequest: URLRequest! - var mockSession: URLSessionable! - - override func tearDown() { - super.tearDown() - mockRequestManager = nil - dummyURLRequest = nil - mockSession = nil - } - - func testRequestManagerErrorConsumptionHandling() { - // Given - let requestExpectation = expectation(description: "requestExpectation") - givenCorruptedManager() - - // When - mockRequestManager.request(dummyURLRequest) { result in - switch result { - case .success: - XCTFail("A failing response shouldn't produce a successful status code") - requestExpectation.fulfill() - case .failure(let error): - XCTAssertEqual(error, NetworkError.malformedRequest) - requestExpectation.fulfill() - } - } - - // Then - wait(for: [requestExpectation], timeout: 1) - } - - func testRequestManagerSuccessConsumptionHandling() { - // Given - let requestExpectation = expectation(description: "requestExpectation") - givenProperSetManager() - - // When - mockRequestManager.request(dummyURLRequest) { result in - switch result { - case .success(let data): - XCTAssertNotEqual(data.count, 0) - requestExpectation.fulfill() - case .failure(let error): - XCTFail("Failed due to: \(error.localizedDescription)") - requestExpectation.fulfill() - } - } - - // Then - wait(for: [requestExpectation], timeout: 1) - } -} - -private extension MauriNetSessionTestCases { - func givenCorruptedManager() { - mockSession = URLFailingSessionMock() - mockRequestManager = RequestManager(session: mockSession) - dummyURLRequest = URLRequest(url: URL(validURL: "loremIpsum")) - } - - func givenProperSetManager() { - mockSession = URLSuccessSessionMock() - mockRequestManager = RequestManager(session: mockSession) - dummyURLRequest = URLRequest(url: URL(validURL: "loremIpsum")) - } -} diff --git a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift b/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift deleted file mode 100644 index 5d5621d..0000000 --- a/Tests/MauriNetTests/Tests/MauriNetURLBuilderTestCases.swift +++ /dev/null @@ -1,135 +0,0 @@ - -import XCTest -#if canImport(FoundationNetworking) -import FoundationNetworking -#endif -@testable import MauriNet - -final class MauriNetURLBuilderTestCases: XCTestCase { - var root: String! - var path: String! - var endpoint: APIEndpoint! - var endpointBuilder: EndpointBuilder! - var resultingURL: URLRequest! - var query: [String: String]! - - override func setUp() { - super.setUp() - givenDummyURL() - } - - override func tearDown() { - super.tearDown() - root = nil - path = nil - endpoint = nil - endpointBuilder = nil - resultingURL = nil - query = nil - } - - func testURLAssemble() { - whenDefaultSetupIsReady() - thenVerifyAValidURLIsAssembled() - } - - func testURLProperConfiguration() { - whenCustomConfigurationIsSet() - thenVerifyCustomConfigurationIsHandledProperly() - } - - func testBuilderForURLWithQueryParams() throws { - whenDefaultSetupIsReady() - try thenVerifyQueryParamsAreProperlyBuilt() - } - - func testConvenienceBuilderAPI() { - whenCustomConfigurationIsSet() - whenURLIsAssembledUsingConvenienceBuilder() - thenVerifyProperBuiltFor(assembledURL: resultingURL, against: endpoint) - } - - func testConvenienceBuilderAPIWithParameters() throws { - whenDefaultSetupIsReady() - whenURLIWithParamssAssembledUsingConvenienceBuilder() - try thenVerifyProperQueryBuiltFor(assembledURL: resultingURL, query: query) - } - - func testDefaultCaseForConvenienceAPIBuilder() { - } -} - -private extension MauriNetURLBuilderTestCases { - func givenDummyURL() { - root = "github.com" - path = "testing" - query = ["param": "1", "param2": "lalala"] - } - - func whenDefaultSetupIsReady() { - endpoint = APIEndpoint(host: root) - } - - func whenCustomConfigurationIsSet() { - endpoint = APIEndpoint(host: root, httpMethod: .post, scheme: .unsafe, timeout: 30) - } - - func whenURLIsAssembledUsingConvenienceBuilder() { - endpointBuilder = EndpointBuilder(endpointSetup: endpoint) - resultingURL = endpointBuilder.assembleRequest(path: path) - } - - func whenURLIWithParamssAssembledUsingConvenienceBuilder() { - endpointBuilder = EndpointBuilder(endpointSetup: endpoint) - resultingURL = endpointBuilder.assembleRequest(path: path, queryParameters: query) - } - - func thenVerifyAValidURLIsAssembled() { - switch endpoint.buildRequest(for: path) { - case .success(let assembledURL): - XCTAssertEqual(assembledURL.url?.absoluteString, "https://github.com/testing") - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyCustomConfigurationIsHandledProperly() { - switch endpoint.buildRequest(for: path) { - case .success(let assembledURL): - thenVerifyProperBuiltFor(assembledURL: assembledURL, against: endpoint) - XCTAssertEqual(assembledURL.url?.absoluteString, "http://github.com/testing") - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyQueryParamsAreProperlyBuilt() throws { - let path = "testing" - - switch endpoint.buildRequest(for: path, with: query) { - case .success(let assembledURL): - try thenVerifyProperQueryBuiltFor(assembledURL: assembledURL, query: query) - case .failure(let error): - XCTFail("Failed due to \(error.localizedDescription)") - } - } - - func thenVerifyProperBuiltFor(assembledURL: URLRequest, against config: APIEndpoint) { - XCTAssertEqual(assembledURL.url?.host, config.host) - XCTAssertEqual(assembledURL.httpMethod, config.httpMethod.value) - XCTAssertEqual(assembledURL.timeoutInterval, config.timeout) - } - - func thenVerifyProperQueryBuiltFor(assembledURL: URLRequest, query: [String: String]) throws { - let resultingURL = try XCTUnwrap(assembledURL.url?.absoluteString) - let urlWithComponents = URLComponents(validURL: resultingURL) - - XCTAssertNotNil(assembledURL.url?.query) - XCTAssertEqual(urlWithComponents.queryItems?.count, query.count) - try urlWithComponents.queryItems?.forEach { item in - let value = try XCTUnwrap(item.value) - XCTAssertEqual(urlWithComponents.queryItems?[item.name], query[item.name]) - XCTAssertEqual(urlWithComponents.queryItems?[value], query[value]) - } - } -}